From fa21bf27389cebc06c670cb4f8fae772cc93b5f3 Mon Sep 17 00:00:00 2001 From: Ayu Date: Mon, 2 Sep 2024 15:50:15 +0300 Subject: [PATCH] feat(tracker): support page view custom props (#132) * feat(tracker): rewrite attribute logic to use specific selectors * feat(tracker): add page events variant * perf(tracker): deduplicate some code in page events * perf(tracker): inline data attribute string * feat(core): add pageview props support * test: update e2e fixtures * feat: add page events checkbox to homepage * fix: remove unload page property feature * fix(core): serve new scripts with correct paths * test(tracker): include page load tests and new button format * test: serve page events file * test: use real load test values --- core/.golangci.yml | 186 ++------- core/.ogen.yml | 9 +- core/api/oas_json_gen.go | 178 +++++++- core/api/oas_response_encoders_gen.go | 160 ------- core/api/oas_schemas_gen.go | 179 +++++++- core/api/oas_validators_gen.go | 4 +- core/cmd/config.go | 1 + core/cmd/main.go | 2 +- core/db/db.go | 2 +- core/db/duckdb/client.go | 10 +- core/db/duckdb/event.go | 223 +++++----- core/db/duckdb/event_test.go | 4 +- core/db/duckdb/helpers_test.go | 31 +- core/db/duckdb/locale_test.go | 24 +- core/db/duckdb/pages_test.go | 8 +- core/db/duckdb/properties.go | 1 - core/db/duckdb/referrers_test.go | 16 +- core/db/duckdb/summary_test.go | 20 +- core/db/duckdb/testdata/generate.go | 4 +- core/db/duckdb/time_test.go | 8 +- core/db/duckdb/types_test.go | 24 +- core/db/duckdb/utm_test.go | 24 +- core/db/filter.go | 2 +- core/db/sqlite/helpers_test.go | 8 +- core/middlewares/auth.go | 4 +- core/middlewares/errors.go | 2 +- core/model/auth.go | 2 +- core/model/errors.go | 4 +- core/model/settings.go | 4 +- core/model/user.go | 12 +- core/model/websites.go | 8 +- core/openapi.yaml | 13 +- core/services/assets.go | 32 +- core/services/event.go | 74 +++- core/services/oas.go | 54 +-- core/services/users.go | 20 +- core/services/websites.go | 20 +- core/util/auth.go | 26 +- core/util/auth_test.go | 18 +- core/util/cache.go | 6 +- core/util/cache_test.go | 4 +- dashboard/app/api/types.d.ts | 6 +- dashboard/app/components/stats/Filter.tsx | 6 +- dashboard/app/routes/settings.tracker.tsx | 73 +++- tracker/README.md | 11 +- tracker/Taskfile.yaml | 12 +- tracker/dist/click-events.js | 383 +++++++++++++++++ tracker/dist/click-events.min.js | 1 + tracker/dist/click-events.page-events.js | 391 ++++++++++++++++++ tracker/dist/click-events.page-events.min.js | 1 + tracker/dist/default.js | 7 +- .../dist/{tagged-events.js => page-events.js} | 83 ++-- tracker/dist/page-events.min.js | 1 + tracker/dist/tagged-events.min.js | 1 - tracker/scripts/build.mjs | 15 +- tracker/src/tracker.js | 103 +++-- tracker/tests/fixtures/history/index.html | 106 +++-- tracker/tests/fixtures/serve.js | 2 +- tracker/tests/fixtures/simple/about.html | 42 +- tracker/tests/fixtures/simple/contact.html | 42 +- tracker/tests/fixtures/simple/index.html | 42 +- .../{tagged-events.js => click-events.js} | 8 +- tracker/tests/helpers/helpers.js | 12 +- .../tests/helpers/{page.js => load-unload.js} | 41 +- 64 files changed, 1917 insertions(+), 903 deletions(-) create mode 100644 tracker/dist/click-events.js create mode 100644 tracker/dist/click-events.min.js create mode 100644 tracker/dist/click-events.page-events.js create mode 100644 tracker/dist/click-events.page-events.min.js rename tracker/dist/{tagged-events.js => page-events.js} (85%) create mode 100644 tracker/dist/page-events.min.js delete mode 100644 tracker/dist/tagged-events.min.js rename tracker/tests/helpers/{tagged-events.js => click-events.js} (88%) rename tracker/tests/helpers/{page.js => load-unload.js} (89%) diff --git a/core/.golangci.yml b/core/.golangci.yml index d2b51a07..5eaacd78 100644 --- a/core/.golangci.yml +++ b/core/.golangci.yml @@ -6,12 +6,6 @@ run: # This file contains only configs which differ from defaults. # All possible options can be found here https://github.com/golangci/golangci-lint/blob/master/.golangci.reference.yml linters-settings: - errcheck: - # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. - # Such cases aren't reported by default. - # Default: false - check-type-assertions: true - exhaustive: # Program elements to check for exhaustiveness. # Default: [ switch ] @@ -19,35 +13,6 @@ linters-settings: - switch - map - funlen: - # Checks the number of lines in a function. - # If lower than 0, disable the check. - # Default: 60 - lines: 100 - # Checks the number of statements in a function. - # If lower than 0, disable the check. - # Default: 40 - statements: 50 - - gocognit: - # Minimal code complexity to report. - # Default: 30 (but we recommend 10-20) - min-complexity: 30 - - gocritic: - # Settings passed to gocritic. - # The settings key is the name of a supported gocritic checker. - # The list of supported checkers can be find in https://go-critic.github.io/overview. - settings: - captLocal: - # Whether to restrict checker to params only. - # Default: true - paramsOnly: false - underef: - # Whether to skip (*x).method() calls where x is a pointer receiver. - # Default: true - skipRecvDeref: false - govet: # Enable all analyzers. # Default: false @@ -59,138 +24,63 @@ linters-settings: - fieldalignment # too strict - shadow # too strict - nakedret: - # Make an issue if func has more lines of code than this setting, and it has naked returns. - # Default: 30 - max-func-lines: 0 - - nolintlint: - # Exclude following linters from requiring an explanation. - # Default: [] - allow-no-explanation: [funlen, gocognit, lll] - # Enable to require an explanation of nonzero length after each nolint directive. - # Default: false - require-explanation: true - # Enable to require nolint directives to mention the specific linter being suppressed. - # Default: false - require-specific: true - revive: rules: - # Disable var-naming due to auto-generated handlers. - - name: var-naming - disabled: true - - stylecheck: - # Remove ST1003 due to auto-generated handlers. - checks: ["-ST1000", "-ST1016", "-ST1020", "-ST1021", "-ST1022"] - - tenv: - # The option `all` will run against whole test files (`_test.go`) regardless of method/function signatures. - # Otherwise, only methods that take `*testing.T`, `*testing.B`, and `testing.TB` as arguments are checked. - # Default: false - all: true - wrapcheck: - ignorePackageGlobs: - - services + - name: unused-parameter + severity: warning + arguments: + - allowRegex: "^_.+$" linters: - disable-all: true - enable: - ## enabled by default - - errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases - - gosimple # specializes in simplifying a code - - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string - - ineffassign # detects when assignments to existing variables are not used - - staticcheck # is a go vet on steroids, applying a ton of static analysis checks - - typecheck # like the front-end of a Go compiler, parses and type-checks Go code - - unused # checks for unused constants, variables, functions and types - ## disabled by default - - asasalint # checks for pass []any as any in variadic func(...any) - - asciicheck # checks that your code does not contain non-ASCII identifiers - - bidichk # checks for dangerous unicode character sequences - - bodyclose # checks whether HTTP response body is closed successfully - - containedctx # checks for context.Context contained in a struct - - contextcheck # checks for common mistakes using context - # - cyclop # checks function and package cyclomatic complexity - # - dupl # tool for code clone detection - - durationcheck # checks for two durations multiplied together - - errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error - - errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13 - - exhaustive # checks exhaustiveness of enum switch statements - - exportloopref # checks for pointers to enclosing loop variables - - forbidigo # forbids identifiers - # - funlen # tool for detection of long functions # disabled because of routers and handlers in one function - - gci # controls golang package import order and makes it always deterministic - - gocheckcompilerdirectives # validates go compiler directive comments (//go:) - - gochecknoglobals # checks that no global variables exist - - gochecknoinits # checks that no init functions are present in Go code - # - gocognit # computes and checks the cognitive complexity of functions - - goconst # finds repeated strings that could be replaced by a constant - - gocritic # provides diagnostics that check for bugs, performance and style issues - # - gocyclo # computes and checks the cyclomatic complexity of functions - - gofumpt # checks if the code is formatted with gofumpt - - godot # checks if comments end in a period - - goimports # in addition to fixing imports, goimports also formats your code in the same style as gofmt - - mnd # detects magic numbers - - gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod - - gomodguard # allow and block lists linter for direct Go module dependencies. This is different from depguard where there are different block types for example version constraints and module recommendations - - goprintffuncname # checks that printf-like functions are named with f at the end - - gosec # inspects source code for security problems - # - lll # reports long lines # db statements very long - - loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap) - - makezero # finds slice declarations with non-zero initial length - - mirror # reports wrong mirror patterns of bytes/strings usage - - musttag # enforces field tags in (un)marshaled structs - - nakedret # finds naked returns in functions greater than a specified function length - - nilerr # finds the code that returns nil even if it checks that the error is not nil - - nilnil # checks that there is no simultaneous return of nil error and an invalid value - - noctx # finds sending http request without context.Context - - nolintlint # reports ill-formed or insufficient nolint directives - - nonamedreturns # reports all named returns - - nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL - - prealloc # finds slice declarations that could potentially be preallocated - - predeclared # finds code that shadows one of Go's predeclared identifiers - - promlinter # checks Prometheus metrics naming via promlint - - reassign # checks that package variables are not reassigned - - revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint - - rowserrcheck # checks whether Err of rows is checked successfully - # - sqlclosecheck # Disabled until https://github.com/ryanrolds/sqlclosecheck/issues/35 is resolved - - stylecheck # is a replacement for golint - - tenv # detects using os.Setenv instead of t.Setenv since Go1.17 - - testableexamples # checks if examples are testable (have an expected output) - - testifylint # checks for common mistakes using the testify package - - testpackage # makes you use a separate _test package - - thelper # checks that the helper functions are not used in the test - - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes - - unconvert # removes unnecessary type conversions - - usestdlibvars # detects the possibility to use variables/constants from the Go standard library - - wastedassign # finds wasted assignment statements - - whitespace # detects leading and trailing whitespace - # - wrapcheck # checks that the code does not use the wrapping of errors + disable: + - depguard + - dupl + - err113 + - exhaustruct + - forcetypeassert + - godox + - interfacebloat + - ireturn + - lll + - nilerr + - nlreturn + - paralleltest # TODO: Remove + - sqlclosecheck # https://github.com/ryanrolds/sqlclosecheck/issues/35 + - tagliatelle + - varnamelen + - wsl + - wrapcheck + presets: + - bugs + - comment + - error + - format + - import + - metalinter + - module + - performance + - sql + - style + - test + - unused issues: # Maximum count of issues with the same text. # Set to 0 to disable. # Default: 3 - max-same-issues: 50 + max-same-issues: 20 exclude-rules: - - source: "(noinspection|TODO)" - linters: [godot] - - source: "//noinspection" - linters: [gocritic] - path: "_test\\.go" linters: - bodyclose - - dupl - - funlen - goconst - gosec - noctx - - wrapcheck # Skip auto-generated folders. exclude-dirs: - api - migrations + + exclude-generated: "lax" diff --git a/core/.ogen.yml b/core/.ogen.yml index c7c16e12..b7745786 100644 --- a/core/.ogen.yml +++ b/core/.ogen.yml @@ -3,12 +3,5 @@ generator: enable: - "paths/server" - "webhooks/server" - - "server/response/validation" - disable: - - "paths/client" - - "webhooks/client" - - "client/request/validation" - - "ogen/otel" - - "ogen/unimplemented" - - "debug/example_tests" + disable_all: true convenient_errors: false diff --git a/core/api/oas_json_gen.go b/core/api/oas_json_gen.go index f478c3ea..b92293cd 100644 --- a/core/api/oas_json_gen.go +++ b/core/api/oas_json_gen.go @@ -845,6 +845,12 @@ func (s EventHit) encodeFields(e *jx.Encoder) { s.T.Encode(e) } } + { + if s.D.Set { + e.FieldStart("d") + s.D.Encode(e) + } + } } case EventUnloadEventHit: e.FieldStart("e") @@ -977,15 +983,22 @@ func (s *EventLoad) encodeFields(e *jx.Encoder) { s.T.Encode(e) } } + { + if s.D.Set { + e.FieldStart("d") + s.D.Encode(e) + } + } } -var jsonFieldsNameOfEventLoad = [6]string{ +var jsonFieldsNameOfEventLoad = [7]string{ 0: "b", 1: "u", 2: "r", 3: "p", 4: "q", 5: "t", + 6: "d", } // Decode decodes EventLoad from json. @@ -1065,6 +1078,16 @@ func (s *EventLoad) Decode(d *jx.Decoder) error { }(); err != nil { return errors.Wrap(err, "decode field \"t\"") } + case "d": + if err := func() error { + s.D.Reset() + if err := s.D.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"d\"") + } default: return d.Skip() } @@ -1121,6 +1144,119 @@ func (s *EventLoad) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode implements json.Marshaler. +func (s EventLoadD) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields implements json.Marshaler. +func (s EventLoadD) encodeFields(e *jx.Encoder) { + for k, elem := range s { + e.FieldStart(k) + + elem.Encode(e) + } +} + +// Decode decodes EventLoadD from json. +func (s *EventLoadD) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode EventLoadD to nil") + } + m := s.init() + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + var elem EventLoadDItem + if err := func() error { + if err := elem.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrapf(err, "decode field %q", k) + } + m[string(k)] = elem + return nil + }); err != nil { + return errors.Wrap(err, "decode EventLoadD") + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s EventLoadD) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *EventLoadD) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes EventLoadDItem as json. +func (s EventLoadDItem) Encode(e *jx.Encoder) { + switch s.Type { + case StringEventLoadDItem: + e.Str(s.String) + case IntEventLoadDItem: + e.Int(s.Int) + case BoolEventLoadDItem: + e.Bool(s.Bool) + } +} + +// Decode decodes EventLoadDItem from json. +func (s *EventLoadDItem) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode EventLoadDItem to nil") + } + // Sum type type_discriminator. + switch t := d.Next(); t { + case jx.Bool: + v, err := d.Bool() + s.Bool = bool(v) + if err != nil { + return err + } + s.Type = BoolEventLoadDItem + case jx.Number: + v, err := d.Int() + s.Int = int(v) + if err != nil { + return err + } + s.Type = IntEventLoadDItem + case jx.String: + v, err := d.Str() + s.String = string(v) + if err != nil { + return err + } + s.Type = StringEventLoadDItem + default: + return errors.Errorf("unexpected json type %q", t) + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s EventLoadDItem) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *EventLoadDItem) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode implements json.Marshaler. func (s *EventUnload) Encode(e *jx.Encoder) { e.ObjStart() @@ -1908,6 +2044,40 @@ func (s *NotFoundErrorError) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode encodes EventLoadD as json. +func (o OptEventLoadD) Encode(e *jx.Encoder) { + if !o.Set { + return + } + o.Value.Encode(e) +} + +// Decode decodes EventLoadD from json. +func (o *OptEventLoadD) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptEventLoadD to nil") + } + o.Set = true + o.Value = make(EventLoadD) + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptEventLoadD) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptEventLoadD) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode encodes float32 as json. func (o OptFloat32) Encode(e *jx.Encoder) { if !o.Set { @@ -5942,8 +6112,10 @@ func (s *UserSettingsScriptTypeItem) Decode(d *jx.Decoder) error { switch UserSettingsScriptTypeItem(v) { case UserSettingsScriptTypeItemDefault: *s = UserSettingsScriptTypeItemDefault - case UserSettingsScriptTypeItemTaggedEvents: - *s = UserSettingsScriptTypeItemTaggedEvents + case UserSettingsScriptTypeItemClickEvents: + *s = UserSettingsScriptTypeItemClickEvents + case UserSettingsScriptTypeItemPageEvents: + *s = UserSettingsScriptTypeItemPageEvents default: *s = UserSettingsScriptTypeItem(v) } diff --git a/core/api/oas_response_encoders_gen.go b/core/api/oas_response_encoders_gen.go index f1e45188..a5e3c261 100644 --- a/core/api/oas_response_encoders_gen.go +++ b/core/api/oas_response_encoders_gen.go @@ -230,14 +230,6 @@ func encodeGetEventPingResponse(response GetEventPingRes, w http.ResponseWriter) func encodeGetUserResponse(response GetUserRes, w http.ResponseWriter) error { switch response := response.(type) { case *UserGet: - if err := func() error { - if err := response.Validate(); err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "validate") - } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(200) @@ -305,14 +297,6 @@ func encodeGetUserResponse(response GetUserRes, w http.ResponseWriter) error { func encodeGetUserUsageResponse(response GetUserUsageRes, w http.ResponseWriter) error { switch response := response.(type) { case *UserUsageGet: - if err := func() error { - if err := response.Validate(); err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "validate") - } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(200) @@ -356,14 +340,6 @@ func encodeGetUserUsageResponse(response GetUserUsageRes, w http.ResponseWriter) func encodeGetWebsiteIDBrowsersResponse(response GetWebsiteIDBrowsersRes, w http.ResponseWriter) error { switch response := response.(type) { case *StatsBrowsers: - if err := func() error { - if err := response.Validate(); err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "validate") - } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(200) @@ -443,14 +419,6 @@ func encodeGetWebsiteIDBrowsersResponse(response GetWebsiteIDBrowsersRes, w http func encodeGetWebsiteIDCampaignsResponse(response GetWebsiteIDCampaignsRes, w http.ResponseWriter) error { switch response := response.(type) { case *StatsUTMCampaigns: - if err := func() error { - if err := response.Validate(); err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "validate") - } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(200) @@ -530,14 +498,6 @@ func encodeGetWebsiteIDCampaignsResponse(response GetWebsiteIDCampaignsRes, w ht func encodeGetWebsiteIDCountryResponse(response GetWebsiteIDCountryRes, w http.ResponseWriter) error { switch response := response.(type) { case *StatsCountries: - if err := func() error { - if err := response.Validate(); err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "validate") - } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(200) @@ -617,14 +577,6 @@ func encodeGetWebsiteIDCountryResponse(response GetWebsiteIDCountryRes, w http.R func encodeGetWebsiteIDDeviceResponse(response GetWebsiteIDDeviceRes, w http.ResponseWriter) error { switch response := response.(type) { case *StatsDevices: - if err := func() error { - if err := response.Validate(); err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "validate") - } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(200) @@ -704,14 +656,6 @@ func encodeGetWebsiteIDDeviceResponse(response GetWebsiteIDDeviceRes, w http.Res func encodeGetWebsiteIDLanguageResponse(response GetWebsiteIDLanguageRes, w http.ResponseWriter) error { switch response := response.(type) { case *StatsLanguages: - if err := func() error { - if err := response.Validate(); err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "validate") - } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(200) @@ -791,14 +735,6 @@ func encodeGetWebsiteIDLanguageResponse(response GetWebsiteIDLanguageRes, w http func encodeGetWebsiteIDMediumsResponse(response GetWebsiteIDMediumsRes, w http.ResponseWriter) error { switch response := response.(type) { case *StatsUTMMediums: - if err := func() error { - if err := response.Validate(); err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "validate") - } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(200) @@ -878,14 +814,6 @@ func encodeGetWebsiteIDMediumsResponse(response GetWebsiteIDMediumsRes, w http.R func encodeGetWebsiteIDOsResponse(response GetWebsiteIDOsRes, w http.ResponseWriter) error { switch response := response.(type) { case *StatsOS: - if err := func() error { - if err := response.Validate(); err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "validate") - } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(200) @@ -965,14 +893,6 @@ func encodeGetWebsiteIDOsResponse(response GetWebsiteIDOsRes, w http.ResponseWri func encodeGetWebsiteIDPagesResponse(response GetWebsiteIDPagesRes, w http.ResponseWriter) error { switch response := response.(type) { case *StatsPages: - if err := func() error { - if err := response.Validate(); err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "validate") - } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(200) @@ -1040,14 +960,6 @@ func encodeGetWebsiteIDPagesResponse(response GetWebsiteIDPagesRes, w http.Respo func encodeGetWebsiteIDPropertiesResponse(response GetWebsiteIDPropertiesRes, w http.ResponseWriter) error { switch response := response.(type) { case *StatsProperties: - if err := func() error { - if err := response.Validate(); err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "validate") - } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(200) @@ -1127,14 +1039,6 @@ func encodeGetWebsiteIDPropertiesResponse(response GetWebsiteIDPropertiesRes, w func encodeGetWebsiteIDReferrersResponse(response GetWebsiteIDReferrersRes, w http.ResponseWriter) error { switch response := response.(type) { case *StatsReferrers: - if err := func() error { - if err := response.Validate(); err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "validate") - } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(200) @@ -1214,14 +1118,6 @@ func encodeGetWebsiteIDReferrersResponse(response GetWebsiteIDReferrersRes, w ht func encodeGetWebsiteIDSourcesResponse(response GetWebsiteIDSourcesRes, w http.ResponseWriter) error { switch response := response.(type) { case *StatsUTMSources: - if err := func() error { - if err := response.Validate(); err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "validate") - } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(200) @@ -1301,14 +1197,6 @@ func encodeGetWebsiteIDSourcesResponse(response GetWebsiteIDSourcesRes, w http.R func encodeGetWebsiteIDSummaryResponse(response GetWebsiteIDSummaryRes, w http.ResponseWriter) error { switch response := response.(type) { case *StatsSummary: - if err := func() error { - if err := response.Validate(); err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "validate") - } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(200) @@ -1376,14 +1264,6 @@ func encodeGetWebsiteIDSummaryResponse(response GetWebsiteIDSummaryRes, w http.R func encodeGetWebsiteIDTimeResponse(response GetWebsiteIDTimeRes, w http.ResponseWriter) error { switch response := response.(type) { case *StatsTime: - if err := func() error { - if err := response.Validate(); err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "validate") - } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(200) @@ -1451,14 +1331,6 @@ func encodeGetWebsiteIDTimeResponse(response GetWebsiteIDTimeRes, w http.Respons func encodeGetWebsitesResponse(response GetWebsitesRes, w http.ResponseWriter) error { switch response := response.(type) { case *GetWebsitesOKApplicationJSON: - if err := func() error { - if err := response.Validate(); err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "validate") - } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(200) @@ -1526,14 +1398,6 @@ func encodeGetWebsitesResponse(response GetWebsitesRes, w http.ResponseWriter) e func encodeGetWebsitesIDResponse(response GetWebsitesIDRes, w http.ResponseWriter) error { switch response := response.(type) { case *WebsiteGet: - if err := func() error { - if err := response.Validate(); err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "validate") - } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(200) @@ -1601,14 +1465,6 @@ func encodeGetWebsitesIDResponse(response GetWebsitesIDRes, w http.ResponseWrite func encodePatchUserResponse(response PatchUserRes, w http.ResponseWriter) error { switch response := response.(type) { case *UserGet: - if err := func() error { - if err := response.Validate(); err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "validate") - } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(200) @@ -1700,14 +1556,6 @@ func encodePatchUserResponse(response PatchUserRes, w http.ResponseWriter) error func encodePatchWebsitesIDResponse(response PatchWebsitesIDRes, w http.ResponseWriter) error { switch response := response.(type) { case *WebsiteGet: - if err := func() error { - if err := response.Validate(); err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "validate") - } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(200) @@ -1951,14 +1799,6 @@ func encodePostEventHitResponse(response PostEventHitRes, w http.ResponseWriter) func encodePostWebsitesResponse(response PostWebsitesRes, w http.ResponseWriter) error { switch response := response.(type) { case *WebsiteGet: - if err := func() error { - if err := response.Validate(); err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "validate") - } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(201) diff --git a/core/api/oas_schemas_gen.go b/core/api/oas_schemas_gen.go index b2955030..05987b8f 100644 --- a/core/api/oas_schemas_gen.go +++ b/core/api/oas_schemas_gen.go @@ -417,6 +417,8 @@ type EventLoad struct { Q bool `json:"q"` // Timezone of the user. T OptString `json:"t"` + // Custom event properties. + D OptEventLoadD `json:"d"` } // GetB returns the value of B. @@ -449,6 +451,11 @@ func (s *EventLoad) GetT() OptString { return s.T } +// GetD returns the value of D. +func (s *EventLoad) GetD() OptEventLoadD { + return s.D +} + // SetB sets the value of B. func (s *EventLoad) SetB(val string) { s.B = val @@ -479,6 +486,113 @@ func (s *EventLoad) SetT(val OptString) { s.T = val } +// SetD sets the value of D. +func (s *EventLoad) SetD(val OptEventLoadD) { + s.D = val +} + +// Custom event properties. +type EventLoadD map[string]EventLoadDItem + +func (s *EventLoadD) init() EventLoadD { + m := *s + if m == nil { + m = map[string]EventLoadDItem{} + *s = m + } + return m +} + +// EventLoadDItem represents sum type. +type EventLoadDItem struct { + Type EventLoadDItemType // switch on this field + String string + Int int + Bool bool +} + +// EventLoadDItemType is oneOf type of EventLoadDItem. +type EventLoadDItemType string + +// Possible values for EventLoadDItemType. +const ( + StringEventLoadDItem EventLoadDItemType = "string" + IntEventLoadDItem EventLoadDItemType = "int" + BoolEventLoadDItem EventLoadDItemType = "bool" +) + +// IsString reports whether EventLoadDItem is string. +func (s EventLoadDItem) IsString() bool { return s.Type == StringEventLoadDItem } + +// IsInt reports whether EventLoadDItem is int. +func (s EventLoadDItem) IsInt() bool { return s.Type == IntEventLoadDItem } + +// IsBool reports whether EventLoadDItem is bool. +func (s EventLoadDItem) IsBool() bool { return s.Type == BoolEventLoadDItem } + +// SetString sets EventLoadDItem to string. +func (s *EventLoadDItem) SetString(v string) { + s.Type = StringEventLoadDItem + s.String = v +} + +// GetString returns string and true boolean if EventLoadDItem is string. +func (s EventLoadDItem) GetString() (v string, ok bool) { + if !s.IsString() { + return v, false + } + return s.String, true +} + +// NewStringEventLoadDItem returns new EventLoadDItem from string. +func NewStringEventLoadDItem(v string) EventLoadDItem { + var s EventLoadDItem + s.SetString(v) + return s +} + +// SetInt sets EventLoadDItem to int. +func (s *EventLoadDItem) SetInt(v int) { + s.Type = IntEventLoadDItem + s.Int = v +} + +// GetInt returns int and true boolean if EventLoadDItem is int. +func (s EventLoadDItem) GetInt() (v int, ok bool) { + if !s.IsInt() { + return v, false + } + return s.Int, true +} + +// NewIntEventLoadDItem returns new EventLoadDItem from int. +func NewIntEventLoadDItem(v int) EventLoadDItem { + var s EventLoadDItem + s.SetInt(v) + return s +} + +// SetBool sets EventLoadDItem to bool. +func (s *EventLoadDItem) SetBool(v bool) { + s.Type = BoolEventLoadDItem + s.Bool = v +} + +// GetBool returns bool and true boolean if EventLoadDItem is bool. +func (s EventLoadDItem) GetBool() (v bool, ok bool) { + if !s.IsBool() { + return v, false + } + return s.Bool, true +} + +// NewBoolEventLoadDItem returns new EventLoadDItem from bool. +func NewBoolEventLoadDItem(v bool) EventLoadDItem { + var s EventLoadDItem + s.SetBool(v) + return s +} + // Page view unload event. // Ref: #/components/schemas/EventUnload type EventUnload struct { @@ -1026,6 +1140,52 @@ func (o OptDateTime) Or(d time.Time) time.Time { return d } +// NewOptEventLoadD returns new OptEventLoadD with value set to v. +func NewOptEventLoadD(v EventLoadD) OptEventLoadD { + return OptEventLoadD{ + Value: v, + Set: true, + } +} + +// OptEventLoadD is optional EventLoadD. +type OptEventLoadD struct { + Value EventLoadD + Set bool +} + +// IsSet returns true if OptEventLoadD was set. +func (o OptEventLoadD) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptEventLoadD) Reset() { + var v EventLoadD + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptEventLoadD) SetTo(v EventLoadD) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptEventLoadD) Get() (v EventLoadD, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptEventLoadD) Or(d EventLoadD) EventLoadD { + if v, ok := o.Get(); ok { + return v + } + return d +} + // NewOptFilterString returns new OptFilterString with value set to v. func NewOptFilterString(v FilterString) OptFilterString { return OptFilterString{ @@ -2716,15 +2876,17 @@ func (s *UserSettingsLanguage) UnmarshalText(data []byte) error { type UserSettingsScriptTypeItem string const ( - UserSettingsScriptTypeItemDefault UserSettingsScriptTypeItem = "default" - UserSettingsScriptTypeItemTaggedEvents UserSettingsScriptTypeItem = "tagged-events" + UserSettingsScriptTypeItemDefault UserSettingsScriptTypeItem = "default" + UserSettingsScriptTypeItemClickEvents UserSettingsScriptTypeItem = "click-events" + UserSettingsScriptTypeItemPageEvents UserSettingsScriptTypeItem = "page-events" ) // AllValues returns all UserSettingsScriptTypeItem values. func (UserSettingsScriptTypeItem) AllValues() []UserSettingsScriptTypeItem { return []UserSettingsScriptTypeItem{ UserSettingsScriptTypeItemDefault, - UserSettingsScriptTypeItemTaggedEvents, + UserSettingsScriptTypeItemClickEvents, + UserSettingsScriptTypeItemPageEvents, } } @@ -2733,7 +2895,9 @@ func (s UserSettingsScriptTypeItem) MarshalText() ([]byte, error) { switch s { case UserSettingsScriptTypeItemDefault: return []byte(s), nil - case UserSettingsScriptTypeItemTaggedEvents: + case UserSettingsScriptTypeItemClickEvents: + return []byte(s), nil + case UserSettingsScriptTypeItemPageEvents: return []byte(s), nil default: return nil, errors.Errorf("invalid value: %q", s) @@ -2746,8 +2910,11 @@ func (s *UserSettingsScriptTypeItem) UnmarshalText(data []byte) error { case UserSettingsScriptTypeItemDefault: *s = UserSettingsScriptTypeItemDefault return nil - case UserSettingsScriptTypeItemTaggedEvents: - *s = UserSettingsScriptTypeItemTaggedEvents + case UserSettingsScriptTypeItemClickEvents: + *s = UserSettingsScriptTypeItemClickEvents + return nil + case UserSettingsScriptTypeItemPageEvents: + *s = UserSettingsScriptTypeItemPageEvents return nil default: return errors.Errorf("invalid value: %q", data) diff --git a/core/api/oas_validators_gen.go b/core/api/oas_validators_gen.go index cf053d17..571fb589 100644 --- a/core/api/oas_validators_gen.go +++ b/core/api/oas_validators_gen.go @@ -1269,7 +1269,9 @@ func (s UserSettingsScriptTypeItem) Validate() error { switch s { case "default": return nil - case "tagged-events": + case "click-events": + return nil + case "page-events": return nil default: return errors.Errorf("invalid value: %v", s) diff --git a/core/cmd/config.go b/core/cmd/config.go index c655f174..50ef7e49 100644 --- a/core/cmd/config.go +++ b/core/cmd/config.go @@ -17,6 +17,7 @@ type ServerConfig struct { CacheCleanupInterval time.Duration // CORS Settings. + //nolint: tagalign // It removes the comma. CORSAllowedOrigins []string `env:"CORS_ALLOWED_ORIGINS" envSeparator:","` // Timeout settings. diff --git a/core/cmd/main.go b/core/cmd/main.go index 094e4dda..eaa3c073 100644 --- a/core/cmd/main.go +++ b/core/cmd/main.go @@ -92,7 +92,7 @@ func GetVersion() string { } if Commit != "" { - return fmt.Sprintf("Medama Analytics commit=%s", Commit) + return "Medama Analytics commit=" + Commit } return "Medama Development Build" diff --git a/core/db/db.go b/core/db/db.go index 8f41c656..c04f025c 100644 --- a/core/db/db.go +++ b/core/db/db.go @@ -52,7 +52,7 @@ type AppClient interface { type AnalyticsClient interface { // Events AddEvents(ctx context.Context, event *[]model.EventHit) error - AddPageView(ctx context.Context, event *model.PageViewHit) error + AddPageView(ctx context.Context, event *model.PageViewHit, events *[]model.EventHit) error UpdatePageView(ctx context.Context, event *model.PageViewDuration) error // Pages GetWebsitePages(ctx context.Context, filter *Filters) ([]*model.StatsPages, error) diff --git a/core/db/duckdb/client.go b/core/db/duckdb/client.go index 6f3b4f35..11e43c04 100644 --- a/core/db/duckdb/client.go +++ b/core/db/duckdb/client.go @@ -12,7 +12,7 @@ import ( type Client struct { *sqlx.DB // Map of prepared statements. - statements *haxmap.Map[string, *sqlx.NamedStmt] + statements *haxmap.Map[string, *sqlx.Stmt] } // Compile time check for Client. @@ -42,7 +42,7 @@ func NewClient(host string) (*Client, error) { return &Client{ DB: db, - statements: haxmap.New[string, *sqlx.NamedStmt](), + statements: haxmap.New[string, *sqlx.Stmt](), }, nil } @@ -57,13 +57,13 @@ func (c *Client) Close() error { // GetPreparedStmt returns a prepared statement by name. This is lazy loaded and cached after // the first call. -func (c *Client) GetPreparedStmt(ctx context.Context, name string, query string) (*sqlx.NamedStmt, error) { +func (c *Client) GetPreparedStmt(ctx context.Context, name string, query string) (*sqlx.Stmt, error) { stmt, ok := c.statements.Get(name) if ok { return stmt, nil } - stmt, err := c.DB.PrepareNamedContext(ctx, query) + stmt, err := c.DB.PreparexContext(ctx, query) if err != nil { return nil, errors.Wrap(err, "unable to create prepared statement") } @@ -73,7 +73,7 @@ func (c *Client) GetPreparedStmt(ctx context.Context, name string, query string) } func (c *Client) closeStatements() { - c.statements.ForEach(func(_ string, stmt *sqlx.NamedStmt) bool { + c.statements.ForEach(func(_ string, stmt *sqlx.Stmt) bool { stmt.Close() return true }) diff --git a/core/db/duckdb/event.go b/core/db/duckdb/event.go index 1e550042..b23d3656 100644 --- a/core/db/duckdb/event.go +++ b/core/db/duckdb/event.go @@ -4,6 +4,7 @@ import ( "context" "github.com/go-faster/errors" + "github.com/jmoiron/sqlx" "github.com/medama-io/medama/model" ) @@ -15,63 +16,12 @@ const ( // AddEvent adds an event with a custom property to the database. func (c *Client) AddEvents(ctx context.Context, events *[]model.EventHit) error { - exec := `--sql - INSERT INTO events ( - bid, - batch_id, - group_name, - name, - value, - date_created - ) VALUES ( - :bid, - :batch_id, - :group_name, - :name, - :value, - NOW() - )` - - stmt, err := c.GetPreparedStmt(ctx, addEventName, exec) - if err != nil { - return errors.Wrap(err, "duckdb") - } - - // Start a transaction for batch insert - tx, err := c.DB.BeginTxx(ctx, nil) - if err != nil { - return errors.Wrap(err, "duckdb: begin event hit transaction") - } - defer tx.Rollback() //nolint: errcheck // Called on defer - - txStmt := tx.NamedStmtContext(ctx, stmt) - - for _, event := range *events { - paramMap := map[string]interface{}{ - "bid": event.BID, - "batch_id": event.BatchID, - "group_name": event.Group, - "name": event.Name, - "value": event.Value, - } - - _, err = txStmt.ExecContext(ctx, paramMap) - if err != nil { - return errors.Wrap(err, "duckdb") - } - } - - err = tx.Commit() - if err != nil { - return errors.Wrap(err, "duckdb: commit event hit transaction") - } - - return nil + return c.executeInTransaction(ctx, func(tx *sqlx.Tx) error { + return c.addEventsWithinTransaction(ctx, tx, events) + }) } -// AddPageView adds a page view to the database. -func (c *Client) AddPageView(ctx context.Context, event *model.PageViewHit) error { - exec := `--sql +const addPageViewStmt = `--sql INSERT INTO views ( bid, hostname, @@ -91,75 +41,134 @@ func (c *Client) AddPageView(ctx context.Context, event *model.PageViewHit) erro utm_campaign, date_created ) VALUES ( - :bid, - :hostname, - :pathname, - :is_unique_user, - :is_unique_page, - :referrer_host, - :referrer_group, - :country, - :language_base, - :language_dialect, - :ua_browser, - :ua_os, - :ua_device_type, - :utm_source, - :utm_medium, - :utm_campaign, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, NOW() )` - stmt, err := c.GetPreparedStmt(ctx, addPageViewName, exec) +// AddPageView adds a page view to the database. +func (c *Client) AddPageView(ctx context.Context, event *model.PageViewHit, events *[]model.EventHit) error { + return c.executeInTransaction(ctx, func(tx *sqlx.Tx) error { + stmt, err := c.GetPreparedStmt(ctx, addPageViewName, addPageViewStmt) + if err != nil { + return errors.Wrap(err, "duckdb") + } + + txStmt := tx.StmtxContext(ctx, stmt) + + _, err = txStmt.ExecContext(ctx, + event.BID, + event.Hostname, + event.Pathname, + event.IsUniqueUser, + event.IsUniquePage, + event.ReferrerHost, + event.ReferrerGroup, + event.Country, + event.LanguageBase, + event.LanguageDialect, + event.BrowserName, + event.OS, + event.DeviceType, + event.UTMSource, + event.UTMMedium, + event.UTMCampaign) + if err != nil { + return errors.Wrap(err, "duckdb: execute statement") + } + + return c.addEventsWithinTransaction(ctx, tx, events) + }) +} + +const updatePageViewStmt = `--sql + UPDATE views SET duration_ms = ? WHERE bid = ?` + +// UpdatePageView updates a page view in the database. +func (c *Client) UpdatePageView(ctx context.Context, event *model.PageViewDuration) error { + return c.executeInTransaction(ctx, func(tx *sqlx.Tx) error { + stmt, err := c.GetPreparedStmt(ctx, updatePageViewName, updatePageViewStmt) + if err != nil { + return errors.Wrap(err, "duckdb") + } + + txStmt := tx.StmtxContext(ctx, stmt) + + if _, err := txStmt.ExecContext(ctx, event.DurationMs, event.BID); err != nil { + return errors.Wrap(err, "duckdb: execute statement") + } + + return nil + }) +} + +// executeInTransaction executes the given function within a transaction. +func (c *Client) executeInTransaction(ctx context.Context, fn func(*sqlx.Tx) error) error { + tx, err := c.DB.BeginTxx(ctx, nil) if err != nil { - return errors.Wrap(err, "duckdb") + return errors.Wrap(err, "duckdb: begin transaction") } + defer tx.Rollback() //nolint: errcheck // Called on defer - paramMap := map[string]interface{}{ - "bid": event.BID, - "hostname": event.Hostname, - "pathname": event.Pathname, - "is_unique_user": event.IsUniqueUser, - "is_unique_page": event.IsUniquePage, - "referrer_host": event.ReferrerHost, - "referrer_group": event.ReferrerGroup, - "country": event.Country, - "language_base": event.LanguageBase, - "language_dialect": event.LanguageDialect, - "ua_browser": event.BrowserName, - "ua_os": event.OS, - "ua_device_type": event.DeviceType, - "utm_source": event.UTMSource, - "utm_medium": event.UTMMedium, - "utm_campaign": event.UTMCampaign, + if err := fn(tx); err != nil { + return err } - _, err = stmt.ExecContext(ctx, paramMap) - if err != nil { - return errors.Wrap(err, "db") + if err := tx.Commit(); err != nil { + return errors.Wrap(err, "duckdb: commit transaction") } - return nil } -// UpdatePageView updates a page view in the database. -func (c *Client) UpdatePageView(ctx context.Context, event *model.PageViewDuration) error { - exec := `--sql - UPDATE views SET duration_ms = :duration_ms WHERE bid = :bid` +const addEventStmt = `--sql + INSERT INTO events ( + bid, + batch_id, + group_name, + name, + value, + date_created + ) VALUES ( + ?, + ?, + ?, + ?, + ?, + NOW() + )` - stmt, err := c.GetPreparedStmt(ctx, updatePageViewName, exec) - if err != nil { - return errors.Wrap(err, "db") +// addEventsWithinTransaction adds events within an existing transaction. +func (c *Client) addEventsWithinTransaction(ctx context.Context, tx *sqlx.Tx, events *[]model.EventHit) error { + if events == nil || len(*events) == 0 { + return nil } - paramMap := map[string]interface{}{ - "bid": event.BID, - "duration_ms": event.DurationMs, + stmt, err := c.GetPreparedStmt(ctx, addEventName, addEventStmt) + if err != nil { + return errors.Wrap(err, "duckdb: prepare statement") } - _, err = stmt.ExecContext(ctx, paramMap) - if err != nil { - return errors.Wrap(err, "db") + txStmt := tx.StmtxContext(ctx, stmt) + + for _, event := range *events { + _, err := txStmt.ExecContext(ctx, event.BID, event.BatchID, event.Group, event.Name, event.Value) + if err != nil { + return errors.Wrap(err, "duckdb: execute statement") + } } return nil diff --git a/core/db/duckdb/event_test.go b/core/db/duckdb/event_test.go index a9491c15..aaec474d 100644 --- a/core/db/duckdb/event_test.go +++ b/core/db/duckdb/event_test.go @@ -60,7 +60,7 @@ func TestAddPageView(t *testing.T) { UTMCampaign: "test_campaign", } - err = client.AddPageView(ctx, event) + err = client.AddPageView(ctx, event, nil) assert.NoError(err) rows = client.DB.QueryRow("SELECT COUNT(*) FROM views WHERE hostname = 'add-page-view-test.io'") @@ -87,7 +87,7 @@ func TestUpdatePageView(t *testing.T) { UTMCampaign: "test_campaign", } - err := client.AddPageView(ctx, event) + err := client.AddPageView(ctx, event, nil) assert.NoError(err) event2 := &model.PageViewDuration{ diff --git a/core/db/duckdb/helpers_test.go b/core/db/duckdb/helpers_test.go index 21998a6e..9bc961f8 100644 --- a/core/db/duckdb/helpers_test.go +++ b/core/db/duckdb/helpers_test.go @@ -29,18 +29,18 @@ import ( type Fixture string const ( - SIMPLE_FIXTURE Fixture = "./testdata/fixtures/simple.test.db" + SimpleFixture Fixture = "./testdata/fixtures/simple.test.db" - SMALL_HOSTNAME = "small.example.com" - MEDIUM_HOSTNAME = "medium.example.com" - DOES_NOT_EXIST_HOSTNAME = "does-not-exist.example.com" + SmallHostname = "small.example.com" + MediumHostname = "medium.example.com" + DoesNotExistHostname = "does-not-exist.example.com" ) var ( //nolint:gochecknoglobals // Reason: These are used in every test. - TIME_START = time.Unix(0, 0).Format(model.DateFormat) + TimeStart = time.Unix(0, 0).Format(model.DateFormat) //nolint:gochecknoglobals // Reason: These are used in every test. - TIME_END = time.Now().Add(24 * time.Hour).Format(model.DateFormat) + TimeEnd = time.Now().Add(24 * time.Hour).Format(model.DateFormat) ) type SnapRecords struct { @@ -69,7 +69,7 @@ func NewSnapRecords(slice interface{}) SnapRecords { } interfaceSlice := make([]interface{}, v.Len()) - for i := 0; i < v.Len(); i++ { + for i := range v.Len() { interfaceSlice[i] = v.Index(i).Interface() } @@ -162,8 +162,8 @@ func generateFilterAll(hostname string) []TestCase { baseFilter := &db.Filters{ Hostname: hostname, - PeriodStart: TIME_START, - PeriodEnd: TIME_END, + PeriodStart: TimeStart, + PeriodEnd: TimeEnd, } filterSteps := []struct { @@ -217,22 +217,23 @@ func generateFilterAll(hostname string) []TestCase { return filters } -func getBaseTestCases(hostname string) []TestCase { +func getBaseTestCases(_ string) []TestCase { + hostname := MediumHostname // For now we only have one hostname. tc := []TestCase{ { Name: "Base", Filters: &db.Filters{ Hostname: hostname, - PeriodStart: TIME_START, - PeriodEnd: TIME_END, + PeriodStart: TimeStart, + PeriodEnd: TimeEnd, }, }, { Name: "Empty", Filters: &db.Filters{ - Hostname: DOES_NOT_EXIST_HOSTNAME, - PeriodStart: TIME_START, - PeriodEnd: TIME_END, + Hostname: DoesNotExistHostname, + PeriodStart: TimeStart, + PeriodEnd: TimeEnd, }, }, } diff --git a/core/db/duckdb/locale_test.go b/core/db/duckdb/locale_test.go index f7ebca21..95460bca 100644 --- a/core/db/duckdb/locale_test.go +++ b/core/db/duckdb/locale_test.go @@ -7,9 +7,9 @@ import ( ) func TestGetWebsiteCountriesSummary(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - testCases := getBaseTestCases(MEDIUM_HOSTNAME) + testCases := getBaseTestCases(MediumHostname) for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { @@ -23,9 +23,9 @@ func TestGetWebsiteCountriesSummary(t *testing.T) { } func TestGetWebsiteCountries(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - testCases := getBaseTestCases(MEDIUM_HOSTNAME) + testCases := getBaseTestCases(MediumHostname) for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { @@ -39,9 +39,9 @@ func TestGetWebsiteCountries(t *testing.T) { } func TestGetWebsiteLanguagesSummary(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - testCases := getBaseTestCases(MEDIUM_HOSTNAME) + testCases := getBaseTestCases(MediumHostname) for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { @@ -55,9 +55,9 @@ func TestGetWebsiteLanguagesSummary(t *testing.T) { } func TestGetWebsiteLanguagesLocaleSummary(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - testCases := getBaseTestCases(MEDIUM_HOSTNAME) + testCases := getBaseTestCases(MediumHostname) for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { @@ -71,9 +71,9 @@ func TestGetWebsiteLanguagesLocaleSummary(t *testing.T) { } func TestGetWebsiteLanguages(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - testCases := getBaseTestCases(MEDIUM_HOSTNAME) + testCases := getBaseTestCases(MediumHostname) for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { @@ -87,9 +87,9 @@ func TestGetWebsiteLanguages(t *testing.T) { } func TestGetWebsiteLanguagesLocale(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - testCases := getBaseTestCases(MEDIUM_HOSTNAME) + testCases := getBaseTestCases(MediumHostname) for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { diff --git a/core/db/duckdb/pages_test.go b/core/db/duckdb/pages_test.go index 13963666..91aa9a45 100644 --- a/core/db/duckdb/pages_test.go +++ b/core/db/duckdb/pages_test.go @@ -7,9 +7,9 @@ import ( ) func TestGetWebsitePagesSummary(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - testCases := getBaseTestCases(MEDIUM_HOSTNAME) + testCases := getBaseTestCases(MediumHostname) for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { @@ -23,9 +23,9 @@ func TestGetWebsitePagesSummary(t *testing.T) { } func TestGetWebsitePages(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - testCases := getBaseTestCases(MEDIUM_HOSTNAME) + testCases := getBaseTestCases(MediumHostname) for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { diff --git a/core/db/duckdb/properties.go b/core/db/duckdb/properties.go index 836c803e..d013c767 100644 --- a/core/db/duckdb/properties.go +++ b/core/db/duckdb/properties.go @@ -70,7 +70,6 @@ func (c *Client) GetWebsiteCustomProperties(ctx context.Context, filter *db.Filt if err != nil { return nil, errors.Wrap(err, "db") } - defer rows.Close() for rows.Next() { diff --git a/core/db/duckdb/referrers_test.go b/core/db/duckdb/referrers_test.go index cf3ddc47..896b955d 100644 --- a/core/db/duckdb/referrers_test.go +++ b/core/db/duckdb/referrers_test.go @@ -7,9 +7,9 @@ import ( ) func TestGetWebsiteReferrersSummary(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - testCases := getBaseTestCases(MEDIUM_HOSTNAME) + testCases := getBaseTestCases(MediumHostname) for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { @@ -23,9 +23,9 @@ func TestGetWebsiteReferrersSummary(t *testing.T) { } func TestGetWebsiteReferrersSummaryGrouped(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - testCases := getBaseTestCases(MEDIUM_HOSTNAME) + testCases := getBaseTestCases(MediumHostname) for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { @@ -39,9 +39,9 @@ func TestGetWebsiteReferrersSummaryGrouped(t *testing.T) { } func TestGetWebsiteReferrers(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - testCases := getBaseTestCases(MEDIUM_HOSTNAME) + testCases := getBaseTestCases(MediumHostname) for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { @@ -55,9 +55,9 @@ func TestGetWebsiteReferrers(t *testing.T) { } func TestGetWebsiteReferrersGrouped(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - testCases := getBaseTestCases(MEDIUM_HOSTNAME) + testCases := getBaseTestCases(MediumHostname) for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { diff --git a/core/db/duckdb/summary_test.go b/core/db/duckdb/summary_test.go index 85a2d1ba..1cef787a 100644 --- a/core/db/duckdb/summary_test.go +++ b/core/db/duckdb/summary_test.go @@ -8,11 +8,11 @@ import ( ) func TestGetWebsiteSummary(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) summary, err := client.GetWebsiteSummary(ctx, &db.Filters{ - Hostname: MEDIUM_HOSTNAME, - PeriodStart: TIME_START, - PeriodEnd: TIME_END, + Hostname: MediumHostname, + PeriodStart: TimeStart, + PeriodEnd: TimeEnd, }) require.NoError(err) @@ -20,12 +20,12 @@ func TestGetWebsiteSummary(t *testing.T) { } func TestGetWebsiteSummaryEmpty(t *testing.T) { - assert, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + assert, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) summary, err := client.GetWebsiteSummary(ctx, &db.Filters{ - Hostname: DOES_NOT_EXIST_HOSTNAME, - PeriodStart: TIME_START, - PeriodEnd: TIME_END, + Hostname: DoesNotExistHostname, + PeriodStart: TimeStart, + PeriodEnd: TimeEnd, }) require.NoError(err) @@ -36,9 +36,9 @@ func TestGetWebsiteSummaryEmpty(t *testing.T) { } func TestGetWebsiteSummaryFilterAll(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - for _, filter := range generateFilterAll(MEDIUM_HOSTNAME) { + for _, filter := range generateFilterAll(MediumHostname) { summary, err := client.GetWebsiteSummary(ctx, filter.Filters) require.NoError(err) diff --git a/core/db/duckdb/testdata/generate.go b/core/db/duckdb/testdata/generate.go index 2fd669e8..ab8443b8 100644 --- a/core/db/duckdb/testdata/generate.go +++ b/core/db/duckdb/testdata/generate.go @@ -31,9 +31,9 @@ var ( // Generate a 1 month interval of data between 1st Jan 2024 and 1st Feb 2024. // //nolint:gochecknoglobals // Reason: These are used in every test. - TIME_START = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + TimeStart = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) //nolint:gochecknoglobals // Reason: These are used in every test. - TIME_END = time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC) + TimeEnd = time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC) ) func main() { diff --git a/core/db/duckdb/time_test.go b/core/db/duckdb/time_test.go index 684a22d9..c032e11a 100644 --- a/core/db/duckdb/time_test.go +++ b/core/db/duckdb/time_test.go @@ -7,9 +7,9 @@ import ( ) func TestGetWebsiteTimeSummary(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - testCases := getBaseTestCases(MEDIUM_HOSTNAME) + testCases := getBaseTestCases(MediumHostname) for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { @@ -23,9 +23,9 @@ func TestGetWebsiteTimeSummary(t *testing.T) { } func TestGetWebsiteTime(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - testCases := getBaseTestCases(MEDIUM_HOSTNAME) + testCases := getBaseTestCases(MediumHostname) for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { diff --git a/core/db/duckdb/types_test.go b/core/db/duckdb/types_test.go index d4007240..4df27b44 100644 --- a/core/db/duckdb/types_test.go +++ b/core/db/duckdb/types_test.go @@ -7,9 +7,9 @@ import ( ) func TestGetWebsiteBrowsersSummary(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - testCases := getBaseTestCases(MEDIUM_HOSTNAME) + testCases := getBaseTestCases(MediumHostname) for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { @@ -23,9 +23,9 @@ func TestGetWebsiteBrowsersSummary(t *testing.T) { } func TestGetWebsiteBrowsers(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - testCases := getBaseTestCases(MEDIUM_HOSTNAME) + testCases := getBaseTestCases(MediumHostname) for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { @@ -39,9 +39,9 @@ func TestGetWebsiteBrowsers(t *testing.T) { } func TestGetWebsiteOSSummary(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - testCases := getBaseTestCases(MEDIUM_HOSTNAME) + testCases := getBaseTestCases(MediumHostname) for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { @@ -55,9 +55,9 @@ func TestGetWebsiteOSSummary(t *testing.T) { } func TestGetWebsiteOS(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - testCases := getBaseTestCases(MEDIUM_HOSTNAME) + testCases := getBaseTestCases(MediumHostname) for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { @@ -71,9 +71,9 @@ func TestGetWebsiteOS(t *testing.T) { } func TestGetWebsiteDevicesSummary(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - testCases := getBaseTestCases(MEDIUM_HOSTNAME) + testCases := getBaseTestCases(MediumHostname) for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { @@ -87,9 +87,9 @@ func TestGetWebsiteDevicesSummary(t *testing.T) { } func TestGetWebsiteDevices(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - testCases := getBaseTestCases(MEDIUM_HOSTNAME) + testCases := getBaseTestCases(MediumHostname) for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { diff --git a/core/db/duckdb/utm_test.go b/core/db/duckdb/utm_test.go index 3a4dc446..192f7f59 100644 --- a/core/db/duckdb/utm_test.go +++ b/core/db/duckdb/utm_test.go @@ -7,9 +7,9 @@ import ( ) func TestGetWebsiteUTMSourcesSummary(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - testCases := getBaseTestCases(MEDIUM_HOSTNAME) + testCases := getBaseTestCases(MediumHostname) for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { @@ -23,9 +23,9 @@ func TestGetWebsiteUTMSourcesSummary(t *testing.T) { } func TestGetWebsiteUTMSources(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - testCases := getBaseTestCases(MEDIUM_HOSTNAME) + testCases := getBaseTestCases(MediumHostname) for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { @@ -39,9 +39,9 @@ func TestGetWebsiteUTMSources(t *testing.T) { } func TestGetWebsiteUTMMediumsSummary(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - testCases := getBaseTestCases(MEDIUM_HOSTNAME) + testCases := getBaseTestCases(MediumHostname) for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { @@ -55,9 +55,9 @@ func TestGetWebsiteUTMMediumsSummary(t *testing.T) { } func TestGetWebsiteUTMMediums(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - testCases := getBaseTestCases(MEDIUM_HOSTNAME) + testCases := getBaseTestCases(MediumHostname) for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { @@ -71,9 +71,9 @@ func TestGetWebsiteUTMMediums(t *testing.T) { } func TestGetWebsiteUTMCampaignsSummary(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - testCases := getBaseTestCases(MEDIUM_HOSTNAME) + testCases := getBaseTestCases(MediumHostname) for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { @@ -87,9 +87,9 @@ func TestGetWebsiteUTMCampaignsSummary(t *testing.T) { } func TestGetWebsiteUTMCampaigns(t *testing.T) { - _, require, ctx, client := UseDatabaseFixture(t, SIMPLE_FIXTURE) + _, require, ctx, client := UseDatabaseFixture(t, SimpleFixture) - testCases := getBaseTestCases(MEDIUM_HOSTNAME) + testCases := getBaseTestCases(MediumHostname) for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { diff --git a/core/db/filter.go b/core/db/filter.go index b098b711..ea768552 100644 --- a/core/db/filter.go +++ b/core/db/filter.go @@ -178,7 +178,7 @@ func CreateFilters(params interface{}, hostname string) *Filters { v := reflect.ValueOf(params) t := v.Type() - for i := 0; i < v.NumField(); i++ { + for i := range v.NumField() { field := v.Field(i) fieldName := t.Field(i).Name diff --git a/core/db/sqlite/helpers_test.go b/core/db/sqlite/helpers_test.go index 227ca17f..d7adf14d 100644 --- a/core/db/sqlite/helpers_test.go +++ b/core/db/sqlite/helpers_test.go @@ -78,14 +78,14 @@ func SetupDatabaseWithWebsites(t *testing.T) (*assert.Assertions, context.Contex assert, ctx, client := SetupDatabaseWithUsers(t) ids := []string{"website1", "website2", "website3"} - user_ids := []string{"test1", "test2", "test3"} + userIDs := []string{"test1", "test2", "test3"} // 3 websites each for 3 users for _, id := range ids { - for _, user_id := range user_ids { + for _, userID := range userIDs { websiteCreate := model.NewWebsite( - user_id, - fmt.Sprintf("%s-%s.com", id, user_id), + userID, + fmt.Sprintf("%s-%s.com", id, userID), 1, 2, ) diff --git a/core/middlewares/auth.go b/core/middlewares/auth.go index 445a8527..b02da80b 100644 --- a/core/middlewares/auth.go +++ b/core/middlewares/auth.go @@ -25,14 +25,14 @@ func NewAuthHandler(auth *util.AuthService) *Handler { // HandleCookieAuth handles cookie based authentication. func (h *Handler) HandleCookieAuth(ctx context.Context, _operationName string, t api.CookieAuth) (context.Context, error) { // Decrypt and read session cookie - userId, err := h.auth.ReadSession(ctx, t.APIKey) + userID, err := h.auth.ReadSession(ctx, t.APIKey) // If session does not exist, return error if err != nil { return nil, model.ErrUnauthorised } // We want to pass the validated user ID to the next handler - ctx = context.WithValue(ctx, model.ContextKeyUserID, userId) + ctx = context.WithValue(ctx, model.ContextKeyUserID, userID) return ctx, nil } diff --git a/core/middlewares/errors.go b/core/middlewares/errors.go index 943e291a..cd6b3069 100644 --- a/core/middlewares/errors.go +++ b/core/middlewares/errors.go @@ -13,7 +13,7 @@ import ( ) // ErrorHandler is a middleware that handles any unhandled errors by ogen. -func ErrorHandler(ctx context.Context, w http.ResponseWriter, req *http.Request, err error) { +func ErrorHandler(_ctx context.Context, w http.ResponseWriter, req *http.Request, err error) { code := ogenerrors.ErrorCode(err) errMessage := strings.ReplaceAll(err.Error(), "\"", "'") diff --git a/core/model/auth.go b/core/model/auth.go index baff9ee6..c5ad2a77 100644 --- a/core/model/auth.go +++ b/core/model/auth.go @@ -6,7 +6,7 @@ type ContextKey string const ( // ContextKeyUserID is the key used to store the user ID in the context. - ContextKeyUserID ContextKey = "userId" + ContextKeyUserID ContextKey = "userID" // SessionCookieName is the name of the session cookie. SessionCookieName = "_me_sess" // SessionDuration is the duration of a session. diff --git a/core/model/errors.go b/core/model/errors.go index 10ab9859..54b7d534 100644 --- a/core/model/errors.go +++ b/core/model/errors.go @@ -20,8 +20,8 @@ var ( ErrSessionNotFound = errors.New("session not found") // Events - // ErrInvalidScreenSize is returned when a screen size is invalid. - ErrInvalidScreenSize = errors.New("screen height or width is too large") + // ErrInvalidProperties is returned when a given custom property is invalid. + ErrInvalidProperties = errors.New("invalid custom property") // ErrInvalidTimezone is returned when a given timezone is invalid. ErrInvalidTimezone = errors.New("invalid country code") // ErrInvalidTrackerEvent is returned when a given tracker event is invalid. diff --git a/core/model/settings.go b/core/model/settings.go index df52ca09..d2ba5694 100644 --- a/core/model/settings.go +++ b/core/model/settings.go @@ -11,10 +11,10 @@ const ( type UserSettings struct { // Account - Language string `json:"language" db:"language"` + Language string `db:"language" json:"language"` // Tracker - ScriptType string `json:"script_type" db:"script_type"` + ScriptType string `db:"script_type" json:"script_type"` } type WebsiteSettings struct{} diff --git a/core/model/user.go b/core/model/user.go index 885977c4..73c77336 100644 --- a/core/model/user.go +++ b/core/model/user.go @@ -1,13 +1,13 @@ package model type User struct { - ID string `json:"id" db:"id"` - Username string `json:"username" db:"username"` - Password string `json:"password" db:"password"` + ID string `db:"id" json:"id"` + Username string `db:"username" json:"username"` + Password string `db:"password" json:"password"` - Settings *UserSettings `json:"settings" db:"settings"` - DateCreated int64 `json:"date_created" db:"date_created"` - DateUpdated int64 `json:"date_updated" db:"date_updated"` + Settings *UserSettings `db:"settings" json:"settings"` + DateCreated int64 `db:"date_created" json:"date_created"` + DateUpdated int64 `db:"date_updated" json:"date_updated"` } // NewUser returns a new instance of User with the given values. diff --git a/core/model/websites.go b/core/model/websites.go index 295b8cfa..4c2ce49d 100644 --- a/core/model/websites.go +++ b/core/model/websites.go @@ -1,11 +1,11 @@ package model type Website struct { - UserID string `json:"user_id" db:"user_id"` - Hostname string `json:"hostname" db:"hostname"` + UserID string `db:"user_id" json:"user_id"` + Hostname string `db:"hostname" json:"hostname"` - DateCreated int64 `json:"date_created" db:"date_created"` - DateUpdated int64 `json:"date_updated" db:"date_updated"` + DateCreated int64 `db:"date_created" json:"date_created"` + DateUpdated int64 `db:"date_updated" json:"date_updated"` } // NewWebsite returns a new instance of Website with the given values. diff --git a/core/openapi.yaml b/core/openapi.yaml index 827b914f..d244934d 100644 --- a/core/openapi.yaml +++ b/core/openapi.yaml @@ -1400,6 +1400,14 @@ components: t: type: string description: Timezone of the user. + d: + type: object + description: Custom event properties. + additionalProperties: + oneOf: + - type: string + - type: integer + - type: boolean required: - b - u @@ -1502,7 +1510,10 @@ components: type: array items: type: string - enum: [default, tagged-events] + enum: + - default + - click-events + - page-events uniqueItems: true UserGet: type: object diff --git a/core/services/assets.go b/core/services/assets.go index 7aaf8e0b..93ca1ff8 100644 --- a/core/services/assets.go +++ b/core/services/assets.go @@ -113,32 +113,30 @@ func (h *SPAHandler) serveFile(w http.ResponseWriter, r *http.Request, filePath func (h *SPAHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { uPath := path.Clean(r.URL.Path) - // Check if the request is for script.js or any file in the /scripts/ directory - if uPath == "/script.js" || strings.HasPrefix(uPath, "/scripts/") { - var scriptFile string - if uPath == "/script.js" { - if h.runtimeConfig.ScriptType.TaggedEvent { - scriptFile = "/scripts/tagged-events.js" - } else { - scriptFile = "/scripts/default.js" - } - } else { - scriptFile = uPath - } + // Check if the request is for script.js + if uPath == "/script.js" { + // Update the request URL to match the actual file being served. + r.URL.Path = h.runtimeConfig.ScriptFileName + h.serveFile(w, r, h.runtimeConfig.ScriptFileName) + return + } - // Update the request URL to match the actual file being served - r.URL.Path = scriptFile - h.serveFile(w, r, scriptFile) + // The path can also check for a file in the /scripts/ directory. + // This isn't typically used for normally serving files. + if strings.HasPrefix(uPath, "/scripts/") { + // Update the request URL to match the actual file being served. + r.URL.Path = uPath + h.serveFile(w, r, uPath) return } - // Check if the file exists in our precomputed ETags + // Check if the file exists in our precomputed ETags. if _, exists := h.fileETags[uPath]; exists { h.serveFile(w, r, uPath) return } - // Serve index.html for all other routes that are not /api + // Serve index.html for all other routes that are not /api. h.serveIndexHTML(w, r) } diff --git a/core/services/event.go b/core/services/event.go index f90f8b97..c7af5365 100644 --- a/core/services/event.go +++ b/core/services/event.go @@ -26,7 +26,7 @@ const ( Unknown = "Unknown" ) -func (h *Handler) GetEventPing(ctx context.Context, params api.GetEventPingParams) (api.GetEventPingRes, error) { +func (h *Handler) GetEventPing(_ctx context.Context, params api.GetEventPingParams) (api.GetEventPingRes, error) { // Check if if-modified-since header is set ifModified := params.IfModifiedSince.Value @@ -89,7 +89,7 @@ func (h *Handler) GetEventPing(ctx context.Context, params api.GetEventPingParam }, nil } -func (h *Handler) PostEventHit(ctx context.Context, req api.EventHit, params api.PostEventHitParams) (api.PostEventHitRes, error) { +func (h *Handler) PostEventHit(ctx context.Context, req api.EventHit, _params api.PostEventHitParams) (api.PostEventHitRes, error) { log := logger.Get() switch req.Type { @@ -261,10 +261,55 @@ func (h *Handler) PostEventHit(ctx context.Context, req api.EventHit, params api Str("device_type", event.DeviceType). Logger() - err = h.analyticsDB.AddPageView(ctx, event) - if err != nil { - log.Error().Err(err).Msg("hit: failed to add page view") - return ErrInternalServerError(err), nil + if req.EventLoad.D.IsSet() { + // Generate batch ID to group all the properties of the same event. + batchIDType, err := typeid.WithPrefix("event") + if err != nil { + return nil, errors.Wrap(err, "typeid custom event") + } + batchID := batchIDType.String() + + events := make([]model.EventHit, 0, len(req.EventLoad.D.Value)) + + for name, item := range req.EventLoad.D.Value { + var value string + + switch item.Type { + case api.StringEventLoadDItem: + value = item.String + case api.IntEventLoadDItem: + value = strconv.Itoa(item.Int) + case api.BoolEventLoadDItem: + value = strconv.FormatBool(item.Bool) + default: + return nil, errors.New("invalid custom event property type: " + string(item.Type)) + } + + events = append(events, model.EventHit{ + BID: event.BID, + BatchID: batchID, + Group: hostname, + Name: name, + Value: value, + }) + } + + log = log.With(). + Str("event_type", string(req.Type)). + Int("event_count", len(events)). + Logger() + + err = h.analyticsDB.AddPageView(ctx, event, &events) + if err != nil { + log.Error().Err(err).Msg("hit: failed to add page view") + return ErrInternalServerError(err), nil + } + } else { + err = h.analyticsDB.AddPageView(ctx, event, nil) + if err != nil { + log.Error().Err(err).Msg("hit: failed to add page view") + return ErrInternalServerError(err), nil + } } // Log success @@ -291,24 +336,28 @@ func (h *Handler) PostEventHit(ctx context.Context, req api.EventHit, params api log.Debug().Msg("hit: updated page view") case api.EventCustomEventHit: + if (len(req.EventCustom.D)) == 0 { + return ErrBadRequest(model.ErrInvalidProperties), nil + } + group := req.EventCustom.G - log = log.With().Str("hostname", group).Logger() + log = log.With().Str("group_name", group).Logger() - // Verify hostname exists + // Verify hostname exists as hostname is used as the group name. if !h.hostnames.Has(group) { log.Warn().Msg("hit: website not found") return ErrNotFound(model.ErrWebsiteNotFound), nil } - events := []model.EventHit{} - // Generate batch ID to group all the properties of the same event. batchIDType, err := typeid.WithPrefix("event") if err != nil { - return ErrInternalServerError(errors.Wrap(err, "services: typeid custom event")), nil + return nil, errors.Wrap(err, "typeid custom event") } batchID := batchIDType.String() + events := make([]model.EventHit, 0, len(req.EventCustom.D)) + for name, item := range req.EventCustom.D { var value string @@ -320,8 +369,7 @@ func (h *Handler) PostEventHit(ctx context.Context, req api.EventHit, params api case api.BoolEventCustomDItem: value = strconv.FormatBool(item.Bool) default: - log.Error().Str("type", string(item.Type)).Msg("hit: invalid custom event property type") - return ErrBadRequest(model.ErrInvalidTrackerEvent), nil + return nil, errors.New("invalid custom event property type: " + string(item.Type)) } events = append(events, model.EventHit{ diff --git a/core/services/oas.go b/core/services/oas.go index c2fa7b89..5e3e36ef 100644 --- a/core/services/oas.go +++ b/core/services/oas.go @@ -2,6 +2,7 @@ package services import ( "context" + "slices" "strings" "github.com/go-faster/errors" @@ -15,18 +16,11 @@ import ( "github.com/medama-io/medama/util/logger" ) -type ScriptType struct { - // Default script that collects page view data. - Default bool - // Script that collects page view data and custom event properties. - TaggedEvent bool -} - // This is a runtime config that is read from user settings in the database. type RuntimeConfig struct { // Tracker settings. // Choose what features of script to serve from /script.js. - ScriptType ScriptType + ScriptFileName string } type Handler struct { @@ -74,7 +68,7 @@ func NewService(ctx context.Context, auth *util.AuthService, sqlite *sqlite.Clie } hostnameCache.AddAll(hostnames) - runtimeConfig, err := NewRuntimeConfig(ctx, sqlite, duckdb) + runtimeConfig, err := NewRuntimeConfig(ctx, sqlite) if err != nil { return nil, errors.Wrap(err, "services init") } @@ -88,20 +82,20 @@ func NewService(ctx context.Context, auth *util.AuthService, sqlite *sqlite.Clie timezoneMap: &tzMap, codeCountryMap: &codeCountryMap, hostnames: &hostnameCache, - RuntimeConfig: runtimeConfig, + RuntimeConfig: &runtimeConfig, }, nil } // NewRuntimeConfig creates a new runtime config. -func NewRuntimeConfig(ctx context.Context, user *sqlite.Client, analytics *duckdb.Client) (*RuntimeConfig, error) { +func NewRuntimeConfig(ctx context.Context, user *sqlite.Client) (RuntimeConfig, error) { // Load the script type from the database. settings, err := user.GetSettings(ctx) if err != nil { - return nil, errors.Wrap(err, "runtime config") + return RuntimeConfig{}, errors.Wrap(err, "runtime config") } - return &RuntimeConfig{ - ScriptType: convertScriptType(settings.ScriptType), + return RuntimeConfig{ + ScriptFileName: convertScriptType(settings.ScriptType), }, nil } @@ -111,7 +105,7 @@ func (r *RuntimeConfig) UpdateConfig(ctx context.Context, meta *sqlite.Client, s if err != nil { return errors.Wrap(err, "script type update config") } - r.ScriptType = convertScriptType(settings.ScriptType) + r.ScriptFileName = convertScriptType(settings.ScriptType) log := logger.Get() log.Warn().Str("script_type", settings.ScriptType).Msg("updated script type") @@ -120,19 +114,31 @@ func (r *RuntimeConfig) UpdateConfig(ctx context.Context, meta *sqlite.Client, s return nil } -// Convert array of script type features split by comma to a ScriptType struct. -func convertScriptType(scriptType string) ScriptType { +// Convert array of script type features split by comma to a script file name. +func convertScriptType(scriptType string) string { features := strings.Split(scriptType, ",") - types := ScriptType{} + // Hot path for basic script. + if scriptType == "default" || len(features) == 0 { + return "/scripts/default.js" + } + + filteredFeatures := make([]string, 0, len(features)) + + // Filter out the default feature. for _, feature := range features { - switch feature { - case "default": - types.Default = true - case "tagged-events": - types.TaggedEvent = true + if feature != "default" { + filteredFeatures = append(filteredFeatures, feature) } } - return types + // Alphabetically sort the features as script files are named alphabetically. + slices.Sort(filteredFeatures) + + var sb strings.Builder + sb.WriteString("/scripts/") + sb.WriteString(strings.Join(filteredFeatures, ".")) + sb.WriteString(".js") + + return sb.String() } diff --git a/core/services/users.go b/core/services/users.go index 964da2ee..d164353a 100644 --- a/core/services/users.go +++ b/core/services/users.go @@ -14,14 +14,14 @@ import ( "github.com/shirou/gopsutil/v4/mem" ) -func (h *Handler) GetUser(ctx context.Context, params api.GetUserParams) (api.GetUserRes, error) { +func (h *Handler) GetUser(ctx context.Context, _params api.GetUserParams) (api.GetUserRes, error) { // Get user id from request context and check if user exists - userId, ok := ctx.Value(model.ContextKeyUserID).(string) + userID, ok := ctx.Value(model.ContextKeyUserID).(string) if !ok { return ErrUnauthorised(model.ErrSessionNotFound), nil } - user, err := h.db.GetUser(ctx, userId) + user, err := h.db.GetUser(ctx, userID) if err != nil { log := logger.Get().With().Err(err).Logger() @@ -51,7 +51,7 @@ func (h *Handler) GetUser(ctx context.Context, params api.GetUserParams) (api.Ge }, nil } -func (h *Handler) GetUserUsage(ctx context.Context, params api.GetUserUsageParams) (api.GetUserUsageRes, error) { +func (h *Handler) GetUserUsage(ctx context.Context, _params api.GetUserUsageParams) (api.GetUserUsageRes, error) { // CPU statistics. cpuCores, err := cpu.CountsWithContext(ctx, false) if err != nil { @@ -110,7 +110,7 @@ func safeConvertUint64ToInt64(value uint64) int64 { return math.MaxInt64 // or another sentinel value or error handling } -func (h *Handler) PatchUser(ctx context.Context, req *api.UserPatch, params api.PatchUserParams) (api.PatchUserRes, error) { +func (h *Handler) PatchUser(ctx context.Context, req *api.UserPatch, _params api.PatchUserParams) (api.PatchUserRes, error) { log := logger.Get() if h.auth.IsDemoMode { log.Debug().Msg("patch user rejected in demo mode") @@ -118,12 +118,12 @@ func (h *Handler) PatchUser(ctx context.Context, req *api.UserPatch, params api. } // Get user id from request context and check if user exists - userId, ok := ctx.Value(model.ContextKeyUserID).(string) + userID, ok := ctx.Value(model.ContextKeyUserID).(string) if !ok { return ErrUnauthorised(model.ErrSessionNotFound), nil } - user, err := h.db.GetUser(ctx, userId) + user, err := h.db.GetUser(ctx, userID) if err != nil { log := log.With().Err(err).Logger() @@ -221,7 +221,7 @@ func (h *Handler) PatchUser(ctx context.Context, req *api.UserPatch, params api. }, nil } -func (h *Handler) DeleteUser(ctx context.Context, params api.DeleteUserParams) (api.DeleteUserRes, error) { +func (h *Handler) DeleteUser(ctx context.Context, _params api.DeleteUserParams) (api.DeleteUserRes, error) { log := logger.Get() if h.auth.IsDemoMode { log.Debug().Msg("delete user rejected in demo mode") @@ -229,12 +229,12 @@ func (h *Handler) DeleteUser(ctx context.Context, params api.DeleteUserParams) ( } // Get user id from request context and check if user exists - userId, ok := ctx.Value(model.ContextKeyUserID).(string) + userID, ok := ctx.Value(model.ContextKeyUserID).(string) if !ok { return ErrUnauthorised(model.ErrSessionNotFound), nil } - user, err := h.db.GetUser(ctx, userId) + user, err := h.db.GetUser(ctx, userID) if err != nil { log = log.With().Err(err).Logger() diff --git a/core/services/websites.go b/core/services/websites.go index 3a4525e5..87b44150 100644 --- a/core/services/websites.go +++ b/core/services/websites.go @@ -18,12 +18,12 @@ func (h *Handler) DeleteWebsitesID(ctx context.Context, params api.DeleteWebsite } // Check if user owns website - userId, ok := ctx.Value(model.ContextKeyUserID).(string) + userID, ok := ctx.Value(model.ContextKeyUserID).(string) if !ok { return ErrUnauthorised(model.ErrSessionNotFound), nil } - websites, err := h.db.ListWebsites(ctx, userId) + websites, err := h.db.ListWebsites(ctx, userID) if err != nil { if errors.Is(err, model.ErrWebsiteNotFound) { return ErrNotFound(err), nil @@ -72,12 +72,12 @@ func (h *Handler) DeleteWebsitesID(ctx context.Context, params api.DeleteWebsite func (h *Handler) GetWebsites(ctx context.Context, params api.GetWebsitesParams) (api.GetWebsitesRes, error) { // Get user ID from context - userId, ok := ctx.Value(model.ContextKeyUserID).(string) + userID, ok := ctx.Value(model.ContextKeyUserID).(string) if !ok { return ErrUnauthorised(model.ErrSessionNotFound), nil } - websites, err := h.db.ListWebsites(ctx, userId) + websites, err := h.db.ListWebsites(ctx, userID) if err != nil { if errors.Is(err, model.ErrWebsiteNotFound) { return ErrNotFound(err), nil @@ -121,7 +121,7 @@ func (h *Handler) GetWebsites(ctx context.Context, params api.GetWebsitesParams) func (h *Handler) GetWebsitesID(ctx context.Context, params api.GetWebsitesIDParams) (api.GetWebsitesIDRes, error) { // Get user ID from context - userId, ok := ctx.Value(model.ContextKeyUserID).(string) + userID, ok := ctx.Value(model.ContextKeyUserID).(string) if !ok { return ErrUnauthorised(model.ErrSessionNotFound), nil } @@ -135,7 +135,7 @@ func (h *Handler) GetWebsitesID(ctx context.Context, params api.GetWebsitesIDPar return nil, errors.Wrap(err, "services") } - if website.UserID != userId { + if website.UserID != userID { return ErrUnauthorised(model.ErrWebsiteNotFound), nil } @@ -152,7 +152,7 @@ func (h *Handler) PatchWebsitesID(ctx context.Context, req *api.WebsitePatch, pa } // Get user ID from context - userId, ok := ctx.Value(model.ContextKeyUserID).(string) + userID, ok := ctx.Value(model.ContextKeyUserID).(string) if !ok { return ErrUnauthorised(model.ErrSessionNotFound), nil } @@ -166,7 +166,7 @@ func (h *Handler) PatchWebsitesID(ctx context.Context, req *api.WebsitePatch, pa return nil, errors.Wrap(err, "services") } - if website.UserID != userId { + if website.UserID != userID { return ErrUnauthorised(model.ErrWebsiteNotFound), nil } @@ -207,7 +207,7 @@ func (h *Handler) PostWebsites(ctx context.Context, req *api.WebsiteCreate) (api } // Get user ID from context - userId, ok := ctx.Value(model.ContextKeyUserID).(string) + userID, ok := ctx.Value(model.ContextKeyUserID).(string) if !ok { return ErrUnauthorised(model.ErrSessionNotFound), nil } @@ -215,7 +215,7 @@ func (h *Handler) PostWebsites(ctx context.Context, req *api.WebsiteCreate) (api // Create website dateCreated := time.Now().Unix() websiteCreate := model.NewWebsite( - userId, + userID, req.Hostname, dateCreated, dateCreated, diff --git a/core/util/auth.go b/core/util/auth.go index 2b440f62..2a288d0b 100644 --- a/core/util/auth.go +++ b/core/util/auth.go @@ -72,7 +72,7 @@ func (a *AuthService) ComparePasswords(suppliedPassword string, storedHash strin } // EncryptSession encrypts a session token and stores it in the cache. -func (a *AuthService) EncryptSession(ctx context.Context, sessionId string, duration time.Duration) (string, error) { +func (a *AuthService) EncryptSession(_ context.Context, sessionID string, _duration time.Duration) (string, error) { // Create a new AES cipher block. block, err := aes.NewCipher(a.aes32Key) if err != nil { @@ -93,7 +93,7 @@ func (a *AuthService) EncryptSession(ctx context.Context, sessionId string, dura } // Authenticate cookie name and value with {name:value} format. - plaintext := fmt.Sprintf("%s:%s", model.SessionCookieName, sessionId) + plaintext := fmt.Sprintf("%s:%s", model.SessionCookieName, sessionID) // Encrypt with nonce for variable ciphertext. encryptedValue := aesgcm.Seal(nonce, nonce, []byte(plaintext), nil) @@ -103,7 +103,7 @@ func (a *AuthService) EncryptSession(ctx context.Context, sessionId string, dura } // DecryptSession decrypts a session cookie to return the session token. -func (a *AuthService) DecryptSession(ctx context.Context, session string) (string, error) { +func (a *AuthService) DecryptSession(_ context.Context, session string) (string, error) { // Create a new AES cipher block. block, err := aes.NewCipher(a.aes32Key) if err != nil { @@ -147,13 +147,13 @@ func (a *AuthService) DecryptSession(ctx context.Context, session string) (strin // CreateSession creates a new session token and stores it in the cache. // This returns an encrypted session token as a cookie. -func (a *AuthService) CreateSession(ctx context.Context, userId string) (*http.Cookie, error) { +func (a *AuthService) CreateSession(ctx context.Context, userID string) (*http.Cookie, error) { // Generate session token. - sessionIdType, err := typeid.WithPrefix("sess") + sessionIDType, err := typeid.WithPrefix("sess") if err != nil { return nil, errors.Wrap(err, "auth: session") } - sessionId := sessionIdType.String() + sessionID := sessionIDType.String() // Create session cookie. cookie := &http.Cookie{ @@ -165,14 +165,14 @@ func (a *AuthService) CreateSession(ctx context.Context, userId string) (*http.C } // Encrypt session token. - encryptedSession, err := a.EncryptSession(ctx, sessionId, model.SessionDuration) + encryptedSession, err := a.EncryptSession(ctx, sessionID, model.SessionDuration) // Update cookie value with encrypted base64 enoded session token. encodedSession := base64.URLEncoding.EncodeToString([]byte(encryptedSession)) cookie.Value = encodedSession // Set session token in cache. - a.Cache.Set(sessionId, userId, model.SessionDuration) + a.Cache.Set(sessionID, userID, model.SessionDuration) return cookie, err } @@ -187,21 +187,21 @@ func (a *AuthService) ReadSession(ctx context.Context, session string) (string, } // Decrypt session token. - sessionId, err := a.DecryptSession(ctx, string(encryptedSession)) + sessionID, err := a.DecryptSession(ctx, string(encryptedSession)) if err != nil { return "", errors.Wrap(err, "session") } // Check if session exists. - userId, err := a.Cache.Get(ctx, sessionId) + userID, err := a.Cache.Get(ctx, sessionID) if err != nil { return "", model.ErrSessionNotFound } - return userId.(string), nil + return userID.(string), nil } // RevokeSession deletes a session token from the cache. -func (a *AuthService) RevokeSession(ctx context.Context, sessionId string) { - a.Cache.Delete(sessionId) +func (a *AuthService) RevokeSession(_ctx context.Context, sessionID string) { + a.Cache.Delete(sessionID) } diff --git a/core/util/auth_test.go b/core/util/auth_test.go index 34a6471f..6134fbd2 100644 --- a/core/util/auth_test.go +++ b/core/util/auth_test.go @@ -40,9 +40,9 @@ func TestAuthCreateAndRead(t *testing.T) { assert.Equal(http.SameSiteLaxMode, cookie.SameSite) // Decrypt cookie - userId, err := auth.ReadSession(ctx, cookie.Value) + userID, err := auth.ReadSession(ctx, cookie.Value) require.NoError(err) - assert.Equal("test_user_id", userId) + assert.Equal("test_user_id", userID) } func TestAuthWithInvalidSession(t *testing.T) { @@ -54,9 +54,9 @@ func TestAuthWithInvalidSession(t *testing.T) { assert.NotNil(cookie) // Decrypt cookie - userId, err := auth.ReadSession(ctx, "invalid_session") + userID, err := auth.ReadSession(ctx, "invalid_session") require.ErrorIs(err, model.ErrInvalidSession) - assert.Equal("", userId) + assert.Equal("", userID) } func TestAuthWithExpiredSession(t *testing.T) { @@ -69,13 +69,13 @@ func TestAuthWithExpiredSession(t *testing.T) { // Delete from cache to simulate expired session base64Decode, err := base64.URLEncoding.DecodeString(cookie.Value) require.NoError(err) - sessionId, err := auth.DecryptSession(ctx, string(base64Decode)) + sessionID, err := auth.DecryptSession(ctx, string(base64Decode)) require.NoError(err) - assert.NotEmpty(sessionId) - auth.Cache.Delete(sessionId) + assert.NotEmpty(sessionID) + auth.Cache.Delete(sessionID) // Try to read from session with expired cookie - userId, err := auth.ReadSession(ctx, cookie.Value) + userID, err := auth.ReadSession(ctx, cookie.Value) require.ErrorIs(err, model.ErrSessionNotFound) - assert.Equal("", userId) + assert.Equal("", userID) } diff --git a/core/util/cache.go b/core/util/cache.go index 006f48bc..2a6e10fd 100644 --- a/core/util/cache.go +++ b/core/util/cache.go @@ -30,7 +30,7 @@ var ( // NewCache creates a new cache that asynchronously cleans // expired entries after the given time passes. -func NewCache(ctx context.Context, cleaningInterval time.Duration) *Cache { +func NewCache(_ctx context.Context, cleaningInterval time.Duration) *Cache { cache := &Cache{ close: make(chan struct{}), } @@ -67,7 +67,7 @@ func NewCache(ctx context.Context, cleaningInterval time.Duration) *Cache { } // Get gets the value for the given key. -func (c *Cache) Get(ctx context.Context, key interface{}) (interface{}, error) { +func (c *Cache) Get(_ context.Context, key interface{}) (interface{}, error) { obj, exists := c.items.Load(key) if !exists { @@ -111,7 +111,7 @@ func (c *Cache) Set(key interface{}, value interface{}, duration time.Duration) // Range calls f sequentially for each key and value present in the cache. // If f returns false, range stops the iteration. -func (c *Cache) Range(ctx context.Context, f func(key, value interface{}) bool) { +func (c *Cache) Range(_ context.Context, f func(key, value interface{}) bool) { now := time.Now().UnixNano() fn := func(key, value interface{}) bool { diff --git a/core/util/cache_test.go b/core/util/cache_test.go index 8fb7d107..886493e2 100644 --- a/core/util/cache_test.go +++ b/core/util/cache_test.go @@ -83,7 +83,7 @@ func TestRange(t *testing.T) { c.Set("world", "World", time.Hour) count := 0 - c.Range(ctx, func(key, value interface{}) bool { + c.Range(ctx, func(_key, _value interface{}) bool { count++ return true }) @@ -98,7 +98,7 @@ func TestRangeTimer(t *testing.T) { c.Set("world", "World", time.Nanosecond) time.Sleep(time.Microsecond) - c.Range(ctx, func(key, value interface{}) bool { + c.Range(ctx, func(_key, _value interface{}) bool { t.Log("Cache range mismatch") t.FailNow() return true diff --git a/dashboard/app/api/types.d.ts b/dashboard/app/api/types.d.ts index 0ac4ecf3..08f16165 100644 --- a/dashboard/app/api/types.d.ts +++ b/dashboard/app/api/types.d.ts @@ -477,6 +477,10 @@ export interface components { q: boolean; /** @description Timezone of the user. */ t?: string; + /** @description Custom event properties. */ + d?: { + [key: string]: string | number | boolean; + }; /** * @description discriminator enum property added by openapi-typescript * @enum {string} @@ -554,7 +558,7 @@ export interface components { * @enum {string} */ language?: "en"; - script_type?: ("default" | "tagged-events")[]; + script_type?: ("default" | "click-events" | "page-events")[]; }; /** * UserGet diff --git a/dashboard/app/components/stats/Filter.tsx b/dashboard/app/components/stats/Filter.tsx index e209b270..4be8272a 100644 --- a/dashboard/app/components/stats/Filter.tsx +++ b/dashboard/app/components/stats/Filter.tsx @@ -259,7 +259,7 @@ export const Filters = () => { } setOpened(!opened); }} - data-medama-filter="open" + data-m:click="filter=open" > @@ -304,7 +304,7 @@ export const Filters = () => { onClick={() => { setOpened(false); }} - data-medama-filter="cancel" + data-m:click="filter=cancel" > Cancel @@ -315,7 +315,7 @@ export const Filters = () => { handleAddFilters(); }} disabled={value === ''} - data-medama-filter="apply" + data-m:click="filter=apply" > Apply diff --git a/dashboard/app/routes/settings.tracker.tsx b/dashboard/app/routes/settings.tracker.tsx index 6bd5a81d..9cb43816 100644 --- a/dashboard/app/routes/settings.tracker.tsx +++ b/dashboard/app/routes/settings.tracker.tsx @@ -36,7 +36,8 @@ const trackerSchema = v.strictObject({ _setting: v.literal('tracker', 'Invalid setting type.'), script_type: v.object({ default: v.boolean(), - 'tagged-events': v.boolean(), + 'click-events': v.boolean(), + 'page-events': v.boolean(), }), }); @@ -108,8 +109,11 @@ export default function Index() { return; } - const [taggedEvents, setTaggedEvents] = useState( - Boolean(user.settings.script_type?.includes('tagged-events')), + const [clickEvents, setClickEvents] = useState( + Boolean(user.settings.script_type?.includes('click-events')), + ); + const [pageEvents, setPageEvents] = useState( + Boolean(user.settings.script_type?.includes('page-events')), ); const code = @@ -123,13 +127,15 @@ export default function Index() { _setting: 'tracker', script_type: { default: true, - 'tagged-events': taggedEvents, + 'click-events': clickEvents, + 'page-events': pageEvents, }, }, validate: valibotResolver(trackerSchema), transformValues: (values) => { // It's difficult to get Radix checkboxes to work with @mantine/form for now - values.script_type['tagged-events'] = taggedEvents; + values.script_type['click-events'] = clickEvents; + values.script_type['page-events'] = pageEvents; // Convert object to comma-separated string const scriptType = Object.entries(values.script_type) @@ -166,7 +172,7 @@ export default function Index() { value="default" tooltip={ <> -

Enable default page view tracking functionality.

+

The default page view tracking functionality.


Read our{' '} @@ -183,22 +189,59 @@ export default function Index() { {...form.getInputProps('script_type.default', { type: 'checkbox' })} /> +

+ Enable custom properties tracking of click events on your + website. +

+
+

+ Read our{' '} + + Custom Properties + {' '} + and{' '} + + Click Events + {' '} + guide for more information. +

+ + } + checked={clickEvents} + onCheckedChange={() => setClickEvents(!clickEvents)} + key={form.key('script_type.click-events')} + {...form.getInputProps('script_type.click-events', { + type: 'checkbox', + })} + /> + -

Enable tracking of tagged events on your website.

+

Enable tracking of page view events on your website.


- Read our Tagged Events guide for more - information. + Read our{' '} + + Custom Properties + {' '} + and{' '} + + Page Events + {' '} + guide for more information.

} - checked={taggedEvents} - onCheckedChange={() => setTaggedEvents(!taggedEvents)} - key={form.key('script_type.tagged-events')} - {...form.getInputProps('script_type.tagged-events', { + checked={pageEvents} + onCheckedChange={() => setPageEvents(!pageEvents)} + key={form.key('script_type.page-events')} + {...form.getInputProps('script_type.page-events', { type: 'checkbox', })} /> diff --git a/tracker/README.md b/tracker/README.md index c500ea45..42a22d41 100644 --- a/tracker/README.md +++ b/tracker/README.md @@ -8,12 +8,13 @@ The minified gzipped tracker is less than 1KB. The size is measured in its compr Our tracker is designed with compression in mind, given that web traffic is usually compressed. For example, certain optimisation techniques like inlining globals with shorter variable names are avoided, as they may decrease the uncompressed size of the tracker but result in an increase in the compressed size due to how dictionary-based compression techniques work. -| File | Size | Compressed (gzip) | Compressed (brotli) | -| ---------------------- | -------------------- | ------------------- | ------------------- | -| `default.min.js` | 1574 bytes (1.54kb) | 792 bytes (0.77 KB) | 639 bytes (0.62 KB) | -| `tagged-events.min.js` | 1959 bytes (1.91 KB) | 958 bytes (0.94 KB) | 775 bytes (0.76 KB) | +| File | Size | Compressed (gzip) | Compressed (brotli) | +| --------------------- | -------------------- | -------------------- | ------------------- | +| `default.min.js` | 1574 bytes (1.54 KB) | 792 bytes (0.77 KB) | 639 bytes (0.62 KB) | +| `page-events.min.js` | 1812 bytes (1.77 KB) | 916 bytes (0.89 KB) | 751 bytes (0.73 KB) | +| `click-events.min.js` | 2053 bytes (2.00 KB) | 1003 bytes (0.98 KB) | 810 bytes (0.79 KB) | -The listed sizes only show the size of the tracker itself with one specific feature. When combining multiple features, the size of the tracker will relatively increase. +The listed sizes only show the size of the tracker itself with one specific feature. When combining multiple features, the size of the tracker will relatively increase (although some features may share code with each other). ## License diff --git a/tracker/Taskfile.yaml b/tracker/Taskfile.yaml index 18d3506a..bcce2985 100644 --- a/tracker/Taskfile.yaml +++ b/tracker/Taskfile.yaml @@ -4,15 +4,18 @@ version: "3" tasks: build: cmds: - - bun run build:default + - task: build:default - bun run build:size build:default: cmds: + - rm -rf ./dist + - mkdir -p ./dist - bun run build:default sources: - ./src/*.js - ./package.json + - ./scripts/*.js generates: - ./dist/*.js @@ -20,8 +23,11 @@ tasks: deps: [build:default] cmds: - mkdir -p ../core/client/scripts - - cp ./dist/default.min.js ../core/client/scripts/default.js - - cp ./dist/tagged-events.min.js ../core/client/scripts/tagged-events.js + - | + files=("default" "click-events" "page-events" "click-events.page-events") + for file in "${files[@]}"; do + cp ./dist/$file.min.js ../core/client/scripts/$file.js + done sources: - ./dist/*.min.js generates: diff --git a/tracker/dist/click-events.js b/tracker/dist/click-events.js new file mode 100644 index 00000000..c17980e9 --- /dev/null +++ b/tracker/dist/click-events.js @@ -0,0 +1,383 @@ +// @ts-check + +/** + * @typedef {Object} HitPayload + * @property {string} b Beacon ID. + * @property {'load'} e Event type. + * @property {string} u Page URL. + * @property {string} r Referrer URL. + * @property {boolean} p If the user is unique or not. + * @property {boolean} q If this is the first time the user has visited this specific page. + * @property {string} t Timezone of the user. + * @property {Object=} d Event custom properties. + */ + +/** + * @typedef {Object} DurationPayload + * @property {string} b Beacon ID. + * @property {'unload'} e Event type. + * @property {number} m Time spent on page. + */ + +/** + * @typedef {Object} CustomPayload + * @property {string} b Beacon ID. + * @property {string} g Group name of events. Currently, only uses the hostname. + * @property {'custom'} e Event type. + * @property {Object} d Event custom properties. + */ + +/** + * Note that we don't try to inline global values such as `self` or `document` because + * while it does reduce actual bundle size, it is LESS efficient with gzip compression + * which should be a more practical benchmark for users. + * + * @see https://github.com/google/closure-compiler/wiki/FAQ#closure-compiler-inlined-all-my-strings-which-made-my-code-size-bigger-why-did-it-do-that + */ +(function () { + // If server-side rendering, bail out. We use document instead of window here as Deno does have + // a window object even on the server. + if (!document) { + return; + } + + /** + * document.currentScript can only be called when the script is being executed. If + * we call the script in an event listener, then it will be null. So we need to + * make a copy of the currentScript object to use later. + */ + const currentScript = document.currentScript; + + /** + * Get API URL from data-api in script tag with the correct protocol. + * If the data-api attribute is not set, then we use the current script's + * src attribute to determine the host. + */ + const host = currentScript.getAttribute('data-api') + ? `${location.protocol}//${currentScript.getAttribute('data-api')}` + : // @ts-ignore - We know this won't be an SVGScriptElement. + currentScript.src.replace(/[^\/]+$/, 'api/'); + + /** + * Generate a unique ID for linking multiple beacon events together for the same page + * view. This is necessary for us to determine how long someone has spent on a page. + * + * @remarks We intentionally use Math.random() instead of the Web Crypto API + * because uniqueness against collisions is not a requirement and is worth + * the tradeoff for bundle size and performance. + */ + const generateUid = () => + Date.now().toString(36) + Math.random().toString(36).substr(2); + + /** + * Unique ID linking multiple beacon events together for the same page view. + */ + let uid = generateUid(); + + /** + * Whether the user is unique or not. + * This is updated when the server checks the ping cache on page load. + */ + let isUnique = true; + + /** + * A temporary variable to store the start time of the page when it is hidden. + */ + let hiddenStartTime = 0; + + /** + * The total time the user has had the page hidden. + * It also signifies the start epoch time of the page. + */ + let hiddenTotalTime = Date.now(); + + /** + * Ensure only the unload beacon is called once. + */ + let isUnloadCalled = false; + + /** + * @remarks We hoist the following variables to the top to let terser infer that it + * can declare these variables together with the other variables in a single line instead + * of separately, which saves us a few bytes. + */ + + /** + * Copy of the original pushState and replaceState functions, used for overriding + * the History API to track navigation changes. + */ + const historyPush = history.pushState; + const historyReplace = history.replaceState; + + /** + * Cleanup temporary variables and reset the unique ID. + */ + const cleanup = () => { + // Main ping cache won't be called again, so we can assume the user is not unique. + // However, isFirstVisit will be called on each page load, so we don't need to reset it. + isUnique = false; + uid = generateUid(); + hiddenStartTime = 0; + hiddenTotalTime = Date.now(); + isUnloadCalled = false; + }; + + /** + * Wraps a history method with additional tracking events. + * @param {!Function} original - The original history method to wrap. + * @returns {function(this:History, *, string, (string | URL)=): void} The wrapped history method. + */ + const wrapHistoryFunc = ( + original, + /** + * @this {History} + * @param {*} _state - The state object. + * @param {string} _title - The title. + * @param {(string | URL)=} url - The URL to navigate to. + * @returns {void} + */ + ) => + function (_state, _title, url) { + if (url && location.pathname !== new URL(url, location.href).pathname) { + sendUnloadBeacon(); + // If the event is a history change, then we need to reset the id and timers + // because the page is not actually reloading the script. + cleanup(); + original.apply(this, arguments); + sendLoadBeacon(); + } else { + original.apply(this, arguments); + } + }; + + /** + * Extracts key-value pairs from a given data attribute. + * @param {Element} target The target element from which to extract data. + * @param {string} attrName The name of the data attribute to extract (e.g., 'data-m:click'). + * @returns {Object} An object containing key-value pairs from the attribute. + */ + const extractDataAttributes = (target, attrName) => + (target.getAttribute(`data-m:${attrName}`) || '') + .split(';') // Split the attribute value into individual key-value pairs. + .reduce((acc, pair) => { + // Split each pair by '=' and trim whitespace. + const [k, v] = pair.split('=').map((s) => s.trim()); + // If both key and value exist, add them to the accumulator object. + if (k && v) acc[k] = v; + return acc; + }, {}); + + /** + * Ping the server with the cache endpoint and read the last modified header to determine + * if the user is unique or not. + * + * If the response is not cached, then the user is unique. If it is cached, then the + * browser will send an If-Modified-Since header indicating the user is not unique. + * + * @param {string} url URL to ping. + * @returns {Promise} Is the cache unique or not. + */ + const pingCache = (url) => + new Promise((resolve) => { + // We use XHR here because fetch GET request requires a CORS + // header to be set on the server, which adds additional requests and + // latency to ping the server. + const xhr = new XMLHttpRequest(); + xhr.onload = () => { + // @ts-ignore - Double equals reduces bundle size. + resolve(xhr.responseText == 0); + }; + xhr.open('GET', url); + xhr.setRequestHeader('Content-Type', 'text/plain'); + xhr.send(); + }); + + /** + * Send a load beacon event to the server when the page is loaded. + * @returns {Promise} + */ + const sendLoadBeacon = async () => { + // Returns true if it is the user's first visit to page, false if not. + // The u query parameter is a cache busting parameter which is the page host and path + // without protocol or query parameters. + pingCache( + host + + 'event/ping?u=' + + encodeURIComponent(location.host + location.pathname), + ).then((isFirstVisit) => { + // We use fetch here because it is more reliable than XHR. + fetch(host + 'event/hit', { + method: 'POST', + body: JSON.stringify( + // biome-ignore format: We use string literals for the keys to tell Closure Compiler to not rename them. + /** + * Payload to send to the server. + * @type {HitPayload} + */ ({ + "b": uid, + "e": "load", + "u": location.href, + "r": document.referrer, + "p": isUnique, + "q": isFirstVisit, + /** + * Get timezone for country detection. + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#return_value + */ + "t": Intl.DateTimeFormat().resolvedOptions().timeZone, + }), + ), + // Will make the response opaque, but we don't need it. + mode: 'no-cors', + }); + }); + }; + + /** + * Send an unload beacon event to the server when the page is unloaded. + * @returns {void} + */ + const sendUnloadBeacon = () => { + if (!isUnloadCalled) { + // We use sendBeacon here because it is more reliable than fetch on page unloads. + // The Fetch API keepalive flag has a few caveats and doesn't work very well on + // Firefox on top of that. Previous experiements also seemed to indicate that + // the fetch API doesn't work well on page unloads. + // See: https://github.com/whatwg/fetch/issues/679 + // + // Some adblockers block this API directly, but since this is the unload event, + // it's an optional event to send. + navigator.sendBeacon( + host + 'event/hit', + JSON.stringify( + // biome-ignore format: We use string literals for the keys to tell Closure Compiler to not rename them. + /** + * Payload to send to the server. + * @type {DurationPayload} + */ + ({ + "b": uid, + "e": "unload", + "m": Date.now() - hiddenTotalTime, + }), + ), + ); + } + + // Ensure unload is only called once. + isUnloadCalled = true; + }; + + /** + * Click event listener to track custom events. + * @param {MouseEvent} event The click event. + * @returns {void} + */ + const clickTracker = (event) => { + // If event is not a left click or middle click, then bail out. + // If the target is not an HTMLElement, then bail out. + if (event.button > 1 || !(event.target instanceof HTMLElement)) return; + + // Extract all data-m:click attributes and send them as custom properties. + const data = extractDataAttributes(event.target, 'click'); + + if (Object.keys(data).length > 0) { + // We use fetch here because it is more reliable than XHR. + fetch(host + 'event/hit', { + method: 'POST', + body: JSON.stringify( + // biome-ignore format: We use string literals for the keys to tell Closure Compiler to not rename them. + /** + * Payload to send to the server. + * @type {CustomPayload} + */ ({ + "b": uid, + "e": "custom", + "g": location.hostname, + "d": data, + }), + ), + // Will make the response opaque, but we don't need it. + mode: 'no-cors', + }); + } + }; + + // Add event listeners to all elements with data-m:click attributes. + for (const elem of document.querySelectorAll('[data-m\\:click]')) { + // Click event listener only listens to primary left clicks. + elem.addEventListener('click', clickTracker); + // Auxclick event listener listens to middle clicks and right clicks. + elem.addEventListener('auxclick', clickTracker); + } + + // Prefer pagehide if available because it's more reliable than unload. + // We also prefer pagehide because it doesn't break bfcache. + if ('onpagehide' in self) { + addEventListener('pagehide', sendUnloadBeacon, { capture: true }); + } else { + // Otherwise, use unload and beforeunload. Using both is significantly more + // reliable than just one due to browser differences. However, this will break + // bfcache, but it's better than nothing. + addEventListener('beforeunload', sendUnloadBeacon, { + capture: true, + }); + addEventListener('unload', sendUnloadBeacon, { capture: true }); + } + + // Visibility change events allow us to track whether a user is tabbed out and + // correct our timings. + addEventListener( + 'visibilitychange', + () => { + if (document.visibilityState == 'hidden') { + // Page is hidden, record the current time. + hiddenStartTime = Date.now(); + } else { + // Page is visible, subtract the hidden time to calculate the total time hidden. + hiddenTotalTime += Date.now() - hiddenStartTime; + hiddenStartTime = 0; + } + }, + { capture: true }, + ); + + pingCache(host + 'event/ping?root').then((response) => { + // The response is a boolean indicating if the user is unique or not. + isUnique = response; + + // Send the first beacon event to the server. + sendLoadBeacon(); + + // Check if hash mode is enabled. If it is, then we need to send a beacon event + // when the hash changes. If disabled, it is safe to override the History API. + if (currentScript.getAttribute('data-hash')) { + // Hash mode is enabled. Add hashchange event listener. + addEventListener('hashchange', sendLoadBeacon, { + capture: true, + }); + } else { + //Add pushState event listeners to track navigation changes with + //router libraries that use the History API. + history.pushState = wrapHistoryFunc(historyPush); + + // replaceState is used by some router libraries to replace the current + // history state instead of pushing a new one. + history.replaceState = wrapHistoryFunc(historyReplace); + + // popstate is fired when the back or forward button is pressed. + addEventListener( + 'popstate', + () => { + sendUnloadBeacon(); + cleanup(); + sendLoadBeacon(); + }, + { + capture: true, + }, + ); + } + }); +})(); diff --git a/tracker/dist/click-events.min.js b/tracker/dist/click-events.min.js new file mode 100644 index 00000000..d2a072e2 --- /dev/null +++ b/tracker/dist/click-events.min.js @@ -0,0 +1 @@ +!function(){if(!document)return;const t=document.currentScript,e=t.getAttribute("data-api")?`${location.protocol}//${t.getAttribute("data-api")}`:t.src.replace(/[^\/]+$/,"api/"),n=()=>Date.now().toString(36)+Math.random().toString(36).substr(2);let o=n(),a=!0,i=0,r=Date.now(),c=!1;const s=history.pushState,d=history.replaceState,p=()=>{a=!1,o=n(),i=0,r=Date.now(),c=!1},l=t=>function(e,n,o){o&&location.pathname!==new URL(o,location.href).pathname?(m(),p(),t.apply(this,arguments),h()):t.apply(this,arguments)},u=t=>new Promise((e=>{const n=new XMLHttpRequest;n.onload=()=>{e(0==n.responseText)},n.open("GET",t),n.setRequestHeader("Content-Type","text/plain"),n.send()})),h=async()=>{u(e+"event/ping?u="+encodeURIComponent(location.host+location.pathname)).then((t=>{fetch(e+"event/hit",{method:"POST",body:JSON.stringify({b:o,e:"load",u:location.href,r:document.referrer,p:a,q:t,t:Intl.DateTimeFormat().resolvedOptions().timeZone}),mode:"no-cors"})}))},m=()=>{c||navigator.sendBeacon(e+"event/hit",JSON.stringify({b:o,e:"unload",m:Date.now()-r})),c=!0},g=t=>{if(t.button>1||!(t.target instanceof HTMLElement))return;const n=(t.target.getAttribute("data-m:click")||"").split(";").reduce(((t,e)=>{const[n,o]=e.split("=").map((t=>t.trim()));return n&&o&&(t[n]=o),t}),{});Object.keys(n).length>0&&fetch(e+"event/hit",{method:"POST",body:JSON.stringify({b:o,e:"custom",g:location.hostname,d:n}),mode:"no-cors"})};for(const t of document.querySelectorAll("[data-m\\:click]"))t.addEventListener("click",g),t.addEventListener("auxclick",g);"onpagehide"in self?addEventListener("pagehide",m,{capture:!0}):(addEventListener("beforeunload",m,{capture:!0}),addEventListener("unload",m,{capture:!0})),addEventListener("visibilitychange",(()=>{"hidden"==document.visibilityState?i=Date.now():(r+=Date.now()-i,i=0)}),{capture:!0}),u(e+"event/ping?root").then((e=>{a=e,h(),t.getAttribute("data-hash")?addEventListener("hashchange",h,{capture:!0}):(history.pushState=l(s),history.replaceState=l(d),addEventListener("popstate",(()=>{m(),p(),h()}),{capture:!0}))}))}(); \ No newline at end of file diff --git a/tracker/dist/click-events.page-events.js b/tracker/dist/click-events.page-events.js new file mode 100644 index 00000000..72ede399 --- /dev/null +++ b/tracker/dist/click-events.page-events.js @@ -0,0 +1,391 @@ +// @ts-check + +/** + * @typedef {Object} HitPayload + * @property {string} b Beacon ID. + * @property {'load'} e Event type. + * @property {string} u Page URL. + * @property {string} r Referrer URL. + * @property {boolean} p If the user is unique or not. + * @property {boolean} q If this is the first time the user has visited this specific page. + * @property {string} t Timezone of the user. + * @property {Object=} d Event custom properties. + */ + +/** + * @typedef {Object} DurationPayload + * @property {string} b Beacon ID. + * @property {'unload'} e Event type. + * @property {number} m Time spent on page. + */ + +/** + * @typedef {Object} CustomPayload + * @property {string} b Beacon ID. + * @property {string} g Group name of events. Currently, only uses the hostname. + * @property {'custom'} e Event type. + * @property {Object} d Event custom properties. + */ + +/** + * Note that we don't try to inline global values such as `self` or `document` because + * while it does reduce actual bundle size, it is LESS efficient with gzip compression + * which should be a more practical benchmark for users. + * + * @see https://github.com/google/closure-compiler/wiki/FAQ#closure-compiler-inlined-all-my-strings-which-made-my-code-size-bigger-why-did-it-do-that + */ +(function () { + // If server-side rendering, bail out. We use document instead of window here as Deno does have + // a window object even on the server. + if (!document) { + return; + } + + /** + * document.currentScript can only be called when the script is being executed. If + * we call the script in an event listener, then it will be null. So we need to + * make a copy of the currentScript object to use later. + */ + const currentScript = document.currentScript; + + /** + * Get API URL from data-api in script tag with the correct protocol. + * If the data-api attribute is not set, then we use the current script's + * src attribute to determine the host. + */ + const host = currentScript.getAttribute('data-api') + ? `${location.protocol}//${currentScript.getAttribute('data-api')}` + : // @ts-ignore - We know this won't be an SVGScriptElement. + currentScript.src.replace(/[^\/]+$/, 'api/'); + + /** + * Generate a unique ID for linking multiple beacon events together for the same page + * view. This is necessary for us to determine how long someone has spent on a page. + * + * @remarks We intentionally use Math.random() instead of the Web Crypto API + * because uniqueness against collisions is not a requirement and is worth + * the tradeoff for bundle size and performance. + */ + const generateUid = () => + Date.now().toString(36) + Math.random().toString(36).substr(2); + + /** + * Unique ID linking multiple beacon events together for the same page view. + */ + let uid = generateUid(); + + /** + * Whether the user is unique or not. + * This is updated when the server checks the ping cache on page load. + */ + let isUnique = true; + + /** + * A temporary variable to store the start time of the page when it is hidden. + */ + let hiddenStartTime = 0; + + /** + * The total time the user has had the page hidden. + * It also signifies the start epoch time of the page. + */ + let hiddenTotalTime = Date.now(); + + /** + * Ensure only the unload beacon is called once. + */ + let isUnloadCalled = false; + + /** + * @remarks We hoist the following variables to the top to let terser infer that it + * can declare these variables together with the other variables in a single line instead + * of separately, which saves us a few bytes. + */ + + /** + * Copy of the original pushState and replaceState functions, used for overriding + * the History API to track navigation changes. + */ + const historyPush = history.pushState; + const historyReplace = history.replaceState; + + /** + * Cleanup temporary variables and reset the unique ID. + */ + const cleanup = () => { + // Main ping cache won't be called again, so we can assume the user is not unique. + // However, isFirstVisit will be called on each page load, so we don't need to reset it. + isUnique = false; + uid = generateUid(); + hiddenStartTime = 0; + hiddenTotalTime = Date.now(); + isUnloadCalled = false; + }; + + /** + * Wraps a history method with additional tracking events. + * @param {!Function} original - The original history method to wrap. + * @returns {function(this:History, *, string, (string | URL)=): void} The wrapped history method. + */ + const wrapHistoryFunc = ( + original, + /** + * @this {History} + * @param {*} _state - The state object. + * @param {string} _title - The title. + * @param {(string | URL)=} url - The URL to navigate to. + * @returns {void} + */ + ) => + function (_state, _title, url) { + if (url && location.pathname !== new URL(url, location.href).pathname) { + sendUnloadBeacon(); + // If the event is a history change, then we need to reset the id and timers + // because the page is not actually reloading the script. + cleanup(); + original.apply(this, arguments); + sendLoadBeacon(); + } else { + original.apply(this, arguments); + } + }; + + /** + * Extracts key-value pairs from a given data attribute. + * @param {Element} target The target element from which to extract data. + * @param {string} attrName The name of the data attribute to extract (e.g., 'data-m:click'). + * @returns {Object} An object containing key-value pairs from the attribute. + */ + const extractDataAttributes = (target, attrName) => + (target.getAttribute(`data-m:${attrName}`) || '') + .split(';') // Split the attribute value into individual key-value pairs. + .reduce((acc, pair) => { + // Split each pair by '=' and trim whitespace. + const [k, v] = pair.split('=').map((s) => s.trim()); + // If both key and value exist, add them to the accumulator object. + if (k && v) acc[k] = v; + return acc; + }, {}); + + /** + * Ping the server with the cache endpoint and read the last modified header to determine + * if the user is unique or not. + * + * If the response is not cached, then the user is unique. If it is cached, then the + * browser will send an If-Modified-Since header indicating the user is not unique. + * + * @param {string} url URL to ping. + * @returns {Promise} Is the cache unique or not. + */ + const pingCache = (url) => + new Promise((resolve) => { + // We use XHR here because fetch GET request requires a CORS + // header to be set on the server, which adds additional requests and + // latency to ping the server. + const xhr = new XMLHttpRequest(); + xhr.onload = () => { + // @ts-ignore - Double equals reduces bundle size. + resolve(xhr.responseText == 0); + }; + xhr.open('GET', url); + xhr.setRequestHeader('Content-Type', 'text/plain'); + xhr.send(); + }); + + /** + * Send a load beacon event to the server when the page is loaded. + * @returns {Promise} + */ + const sendLoadBeacon = async () => { + // Returns true if it is the user's first visit to page, false if not. + // The u query parameter is a cache busting parameter which is the page host and path + // without protocol or query parameters. + pingCache( + host + + 'event/ping?u=' + + encodeURIComponent(location.host + location.pathname), + ).then((isFirstVisit) => { + // We use fetch here because it is more reliable than XHR. + fetch(host + 'event/hit', { + method: 'POST', + body: JSON.stringify( + // biome-ignore format: We use string literals for the keys to tell Closure Compiler to not rename them. + /** + * Payload to send to the server. + * @type {HitPayload} + */ ({ + "b": uid, + "e": "load", + "u": location.href, + "r": document.referrer, + "p": isUnique, + "q": isFirstVisit, + /** + * Get timezone for country detection. + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#return_value + */ + "t": Intl.DateTimeFormat().resolvedOptions().timeZone, + // Helper function to extract data attributes and merge them. + "d": [...document.querySelectorAll('[data-m\\:load]')].reduce( + (acc, elem) => ({ + ...acc, + ...extractDataAttributes(elem, 'load'), + }), + {}, + ), + }), + ), + // Will make the response opaque, but we don't need it. + mode: 'no-cors', + }); + }); + }; + + /** + * Send an unload beacon event to the server when the page is unloaded. + * @returns {void} + */ + const sendUnloadBeacon = () => { + if (!isUnloadCalled) { + // We use sendBeacon here because it is more reliable than fetch on page unloads. + // The Fetch API keepalive flag has a few caveats and doesn't work very well on + // Firefox on top of that. Previous experiements also seemed to indicate that + // the fetch API doesn't work well on page unloads. + // See: https://github.com/whatwg/fetch/issues/679 + // + // Some adblockers block this API directly, but since this is the unload event, + // it's an optional event to send. + navigator.sendBeacon( + host + 'event/hit', + JSON.stringify( + // biome-ignore format: We use string literals for the keys to tell Closure Compiler to not rename them. + /** + * Payload to send to the server. + * @type {DurationPayload} + */ + ({ + "b": uid, + "e": "unload", + "m": Date.now() - hiddenTotalTime, + }), + ), + ); + } + + // Ensure unload is only called once. + isUnloadCalled = true; + }; + + /** + * Click event listener to track custom events. + * @param {MouseEvent} event The click event. + * @returns {void} + */ + const clickTracker = (event) => { + // If event is not a left click or middle click, then bail out. + // If the target is not an HTMLElement, then bail out. + if (event.button > 1 || !(event.target instanceof HTMLElement)) return; + + // Extract all data-m:click attributes and send them as custom properties. + const data = extractDataAttributes(event.target, 'click'); + + if (Object.keys(data).length > 0) { + // We use fetch here because it is more reliable than XHR. + fetch(host + 'event/hit', { + method: 'POST', + body: JSON.stringify( + // biome-ignore format: We use string literals for the keys to tell Closure Compiler to not rename them. + /** + * Payload to send to the server. + * @type {CustomPayload} + */ ({ + "b": uid, + "e": "custom", + "g": location.hostname, + "d": data, + }), + ), + // Will make the response opaque, but we don't need it. + mode: 'no-cors', + }); + } + }; + + // Add event listeners to all elements with data-m:click attributes. + for (const elem of document.querySelectorAll('[data-m\\:click]')) { + // Click event listener only listens to primary left clicks. + elem.addEventListener('click', clickTracker); + // Auxclick event listener listens to middle clicks and right clicks. + elem.addEventListener('auxclick', clickTracker); + } + + // Prefer pagehide if available because it's more reliable than unload. + // We also prefer pagehide because it doesn't break bfcache. + if ('onpagehide' in self) { + addEventListener('pagehide', sendUnloadBeacon, { capture: true }); + } else { + // Otherwise, use unload and beforeunload. Using both is significantly more + // reliable than just one due to browser differences. However, this will break + // bfcache, but it's better than nothing. + addEventListener('beforeunload', sendUnloadBeacon, { + capture: true, + }); + addEventListener('unload', sendUnloadBeacon, { capture: true }); + } + + // Visibility change events allow us to track whether a user is tabbed out and + // correct our timings. + addEventListener( + 'visibilitychange', + () => { + if (document.visibilityState == 'hidden') { + // Page is hidden, record the current time. + hiddenStartTime = Date.now(); + } else { + // Page is visible, subtract the hidden time to calculate the total time hidden. + hiddenTotalTime += Date.now() - hiddenStartTime; + hiddenStartTime = 0; + } + }, + { capture: true }, + ); + + pingCache(host + 'event/ping?root').then((response) => { + // The response is a boolean indicating if the user is unique or not. + isUnique = response; + + // Send the first beacon event to the server. + sendLoadBeacon(); + + // Check if hash mode is enabled. If it is, then we need to send a beacon event + // when the hash changes. If disabled, it is safe to override the History API. + if (currentScript.getAttribute('data-hash')) { + // Hash mode is enabled. Add hashchange event listener. + addEventListener('hashchange', sendLoadBeacon, { + capture: true, + }); + } else { + //Add pushState event listeners to track navigation changes with + //router libraries that use the History API. + history.pushState = wrapHistoryFunc(historyPush); + + // replaceState is used by some router libraries to replace the current + // history state instead of pushing a new one. + history.replaceState = wrapHistoryFunc(historyReplace); + + // popstate is fired when the back or forward button is pressed. + addEventListener( + 'popstate', + () => { + sendUnloadBeacon(); + cleanup(); + sendLoadBeacon(); + }, + { + capture: true, + }, + ); + } + }); +})(); diff --git a/tracker/dist/click-events.page-events.min.js b/tracker/dist/click-events.page-events.min.js new file mode 100644 index 00000000..50b334e0 --- /dev/null +++ b/tracker/dist/click-events.page-events.min.js @@ -0,0 +1 @@ +!function(){if(!document)return;const t=document.currentScript,e=t.getAttribute("data-api")?`${location.protocol}//${t.getAttribute("data-api")}`:t.src.replace(/[^\/]+$/,"api/"),n=()=>Date.now().toString(36)+Math.random().toString(36).substr(2);let o=n(),a=!0,i=0,r=Date.now(),c=!1;const d=history.pushState,s=history.replaceState,l=()=>{a=!1,o=n(),i=0,r=Date.now(),c=!1},p=t=>function(e,n,o){o&&location.pathname!==new URL(o,location.href).pathname?(g(),l(),t.apply(this,arguments),m()):t.apply(this,arguments)},u=(t,e)=>(t.getAttribute("data-m:"+e)||"").split(";").reduce(((t,e)=>{const[n,o]=e.split("=").map((t=>t.trim()));return n&&o&&(t[n]=o),t}),{}),h=t=>new Promise((e=>{const n=new XMLHttpRequest;n.onload=()=>{e(0==n.responseText)},n.open("GET",t),n.setRequestHeader("Content-Type","text/plain"),n.send()})),m=async()=>{h(e+"event/ping?u="+encodeURIComponent(location.host+location.pathname)).then((t=>{fetch(e+"event/hit",{method:"POST",body:JSON.stringify({b:o,e:"load",u:location.href,r:document.referrer,p:a,q:t,t:Intl.DateTimeFormat().resolvedOptions().timeZone,d:[...document.querySelectorAll("[data-m\\:load]")].reduce(((t,e)=>({...t,...u(e,"load")})),{})}),mode:"no-cors"})}))},g=()=>{c||navigator.sendBeacon(e+"event/hit",JSON.stringify({b:o,e:"unload",m:Date.now()-r})),c=!0},y=t=>{if(t.button>1||!(t.target instanceof HTMLElement))return;const n=u(t.target,"click");Object.keys(n).length>0&&fetch(e+"event/hit",{method:"POST",body:JSON.stringify({b:o,e:"custom",g:location.hostname,d:n}),mode:"no-cors"})};for(const t of document.querySelectorAll("[data-m\\:click]"))t.addEventListener("click",y),t.addEventListener("auxclick",y);"onpagehide"in self?addEventListener("pagehide",g,{capture:!0}):(addEventListener("beforeunload",g,{capture:!0}),addEventListener("unload",g,{capture:!0})),addEventListener("visibilitychange",(()=>{"hidden"==document.visibilityState?i=Date.now():(r+=Date.now()-i,i=0)}),{capture:!0}),h(e+"event/ping?root").then((e=>{a=e,m(),t.getAttribute("data-hash")?addEventListener("hashchange",m,{capture:!0}):(history.pushState=p(d),history.replaceState=p(s),addEventListener("popstate",(()=>{g(),l(),m()}),{capture:!0}))}))}(); \ No newline at end of file diff --git a/tracker/dist/default.js b/tracker/dist/default.js index 5427cd3b..249b153c 100644 --- a/tracker/dist/default.js +++ b/tracker/dist/default.js @@ -9,6 +9,7 @@ * @property {boolean} p If the user is unique or not. * @property {boolean} q If this is the first time the user has visited this specific page. * @property {string} t Timezone of the user. + * @property {Object=} d Event custom properties. */ /** @@ -131,12 +132,12 @@ /** * @this {History} * @param {*} _state - The state object. - * @param {string} _unused - The title (unused). + * @param {string} _title - The title. * @param {(string | URL)=} url - The URL to navigate to. * @returns {void} */ ) => - function (_state, _unused, url) { + function (_state, _title, url) { if (url && location.pathname !== new URL(url, location.href).pathname) { sendUnloadBeacon(); // If the event is a history change, then we need to reset the id and timers @@ -149,6 +150,7 @@ } }; + /** * Ping the server with the cache endpoint and read the last modified header to determine * if the user is unique or not. @@ -252,7 +254,6 @@ }; - // Prefer pagehide if available because it's more reliable than unload. // We also prefer pagehide because it doesn't break bfcache. if ('onpagehide' in self) { diff --git a/tracker/dist/tagged-events.js b/tracker/dist/page-events.js similarity index 85% rename from tracker/dist/tagged-events.js rename to tracker/dist/page-events.js index 3675fd3e..4583137f 100644 --- a/tracker/dist/tagged-events.js +++ b/tracker/dist/page-events.js @@ -9,6 +9,7 @@ * @property {boolean} p If the user is unique or not. * @property {boolean} q If this is the first time the user has visited this specific page. * @property {string} t Timezone of the user. + * @property {Object=} d Event custom properties. */ /** @@ -131,12 +132,12 @@ /** * @this {History} * @param {*} _state - The state object. - * @param {string} _unused - The title (unused). + * @param {string} _title - The title. * @param {(string | URL)=} url - The URL to navigate to. * @returns {void} */ ) => - function (_state, _unused, url) { + function (_state, _title, url) { if (url && location.pathname !== new URL(url, location.href).pathname) { sendUnloadBeacon(); // If the event is a history change, then we need to reset the id and timers @@ -149,6 +150,23 @@ } }; + /** + * Extracts key-value pairs from a given data attribute. + * @param {Element} target The target element from which to extract data. + * @param {string} attrName The name of the data attribute to extract (e.g., 'data-m:click'). + * @returns {Object} An object containing key-value pairs from the attribute. + */ + const extractDataAttributes = (target, attrName) => + (target.getAttribute(`data-m:${attrName}`) || '') + .split(';') // Split the attribute value into individual key-value pairs. + .reduce((acc, pair) => { + // Split each pair by '=' and trim whitespace. + const [k, v] = pair.split('=').map((s) => s.trim()); + // If both key and value exist, add them to the accumulator object. + if (k && v) acc[k] = v; + return acc; + }, {}); + /** * Ping the server with the cache endpoint and read the last modified header to determine * if the user is unique or not. @@ -208,6 +226,14 @@ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#return_value */ "t": Intl.DateTimeFormat().resolvedOptions().timeZone, + // Helper function to extract data attributes and merge them. + "d": [...document.querySelectorAll('[data-m\\:load]')].reduce( + (acc, elem) => ({ + ...acc, + ...extractDataAttributes(elem, 'load'), + }), + {}, + ), }), ), // Will make the response opaque, but we don't need it. @@ -251,59 +277,6 @@ isUnloadCalled = true; }; - /** - * Send a custom beacon event to the server. - * @param {Object.} properties Event custom properties. - * @returns {void} - */ - const sendCustomBeacon = (properties) => { - // We use fetch here because it is more reliable than XHR. - fetch(host + 'event/hit', { - method: 'POST', - body: JSON.stringify( - // biome-ignore format: We use string literals for the keys to tell Closure Compiler to not rename them. - /** - * Payload to send to the server. - * @type {CustomPayload} - */ ({ - "b": uid, - "e": "custom", - "g": location.hostname, - "d": properties, - }), - ), - // Will make the response opaque, but we don't need it. - mode: 'no-cors', - }); - }; - - /** - * Click event listener to track custom events. - * @param {MouseEvent} event The click event. - * @returns {void} - */ - const clickTracker = (event) => { - // If event is not a left click or middle click, then bail out. - // If the target is not an HTMLElement, then bail out. - if (event.button > 1 || !(event.target instanceof HTMLElement)) return; - - // Extract all data-medama-* attributes and send them as custom properties. - /** @type {Object} */ - const data = {}; - for (const attr of event.target.attributes) { - if (attr.name.startsWith('data-medama-')) - data[attr.name.slice(12)] = attr.value; - } - - if (Object.keys(data).length > 0) { - sendCustomBeacon(data); - } - }; - - // Click event listener only listens to primary left clicks. - addEventListener('click', clickTracker); - // Auxclick event listener listens to middle clicks and right clicks. - addEventListener('auxclick', clickTracker); // Prefer pagehide if available because it's more reliable than unload. // We also prefer pagehide because it doesn't break bfcache. diff --git a/tracker/dist/page-events.min.js b/tracker/dist/page-events.min.js new file mode 100644 index 00000000..5610255f --- /dev/null +++ b/tracker/dist/page-events.min.js @@ -0,0 +1 @@ +!function(){if(!document)return;const t=document.currentScript,e=t.getAttribute("data-api")?`${location.protocol}//${t.getAttribute("data-api")}`:t.src.replace(/[^\/]+$/,"api/"),n=()=>Date.now().toString(36)+Math.random().toString(36).substr(2);let a=n(),o=!0,r=0,i=Date.now(),d=!1;const s=history.pushState,c=history.replaceState,p=()=>{o=!1,a=n(),r=0,i=Date.now(),d=!1},u=t=>function(e,n,a){a&&location.pathname!==new URL(a,location.href).pathname?(m(),p(),t.apply(this,arguments),h()):t.apply(this,arguments)},l=t=>new Promise((e=>{const n=new XMLHttpRequest;n.onload=()=>{e(0==n.responseText)},n.open("GET",t),n.setRequestHeader("Content-Type","text/plain"),n.send()})),h=async()=>{l(e+"event/ping?u="+encodeURIComponent(location.host+location.pathname)).then((t=>{fetch(e+"event/hit",{method:"POST",body:JSON.stringify({b:a,e:"load",u:location.href,r:document.referrer,p:o,q:t,t:Intl.DateTimeFormat().resolvedOptions().timeZone,d:[...document.querySelectorAll("[data-m\\:load]")].reduce(((t,e)=>{return{...t,...(n=e,(n.getAttribute("data-m:load")||"").split(";").reduce(((t,e)=>{const[n,a]=e.split("=").map((t=>t.trim()));return n&&a&&(t[n]=a),t}),{}))};var n}),{})}),mode:"no-cors"})}))},m=()=>{d||navigator.sendBeacon(e+"event/hit",JSON.stringify({b:a,e:"unload",m:Date.now()-i})),d=!0};"onpagehide"in self?addEventListener("pagehide",m,{capture:!0}):(addEventListener("beforeunload",m,{capture:!0}),addEventListener("unload",m,{capture:!0})),addEventListener("visibilitychange",(()=>{"hidden"==document.visibilityState?r=Date.now():(i+=Date.now()-r,r=0)}),{capture:!0}),l(e+"event/ping?root").then((e=>{o=e,h(),t.getAttribute("data-hash")?addEventListener("hashchange",h,{capture:!0}):(history.pushState=u(s),history.replaceState=u(c),addEventListener("popstate",(()=>{m(),p(),h()}),{capture:!0}))}))}(); \ No newline at end of file diff --git a/tracker/dist/tagged-events.min.js b/tracker/dist/tagged-events.min.js deleted file mode 100644 index 731702fd..00000000 --- a/tracker/dist/tagged-events.min.js +++ /dev/null @@ -1 +0,0 @@ -!function(){if(!document)return;const t=document.currentScript,e=t.getAttribute("data-api")?`${location.protocol}//${t.getAttribute("data-api")}`:t.src.replace(/[^\/]+$/,"api/"),n=()=>Date.now().toString(36)+Math.random().toString(36).substr(2);let a=n(),o=!0,i=0,r=Date.now(),s=!1;const c=history.pushState,d=history.replaceState,p=()=>{o=!1,a=n(),i=0,r=Date.now(),s=!1},h=t=>function(e,n,a){a&&location.pathname!==new URL(a,location.href).pathname?(m(),p(),t.apply(this,arguments),l()):t.apply(this,arguments)},u=t=>new Promise((e=>{const n=new XMLHttpRequest;n.onload=()=>{e(0==n.responseText)},n.open("GET",t),n.setRequestHeader("Content-Type","text/plain"),n.send()})),l=async()=>{u(e+"event/ping?u="+encodeURIComponent(location.host+location.pathname)).then((t=>{fetch(e+"event/hit",{method:"POST",body:JSON.stringify({b:a,e:"load",u:location.href,r:document.referrer,p:o,q:t,t:Intl.DateTimeFormat().resolvedOptions().timeZone}),mode:"no-cors"})}))},m=()=>{s||navigator.sendBeacon(e+"event/hit",JSON.stringify({b:a,e:"unload",m:Date.now()-r})),s=!0},g=t=>{if(t.button>1||!(t.target instanceof HTMLElement))return;const n={};for(const e of t.target.attributes)e.name.startsWith("data-medama-")&&(n[e.name.slice(12)]=e.value);var o;Object.keys(n).length>0&&(o=n,fetch(e+"event/hit",{method:"POST",body:JSON.stringify({b:a,e:"custom",g:location.hostname,d:o}),mode:"no-cors"}))};addEventListener("click",g),addEventListener("auxclick",g),"onpagehide"in self?addEventListener("pagehide",m,{capture:!0}):(addEventListener("beforeunload",m,{capture:!0}),addEventListener("unload",m,{capture:!0})),addEventListener("visibilitychange",(()=>{"hidden"==document.visibilityState?i=Date.now():(r+=Date.now()-i,i=0)}),{capture:!0}),u(e+"event/ping?root").then((e=>{o=e,l(),t.getAttribute("data-hash")?addEventListener("hashchange",l,{capture:!0}):(history.pushState=h(c),history.replaceState=h(d),addEventListener("popstate",(()=>{m(),p(),l()}),{capture:!0}))}))}(); \ No newline at end of file diff --git a/tracker/scripts/build.mjs b/tracker/scripts/build.mjs index 2191eb4f..dc78ad17 100644 --- a/tracker/scripts/build.mjs +++ b/tracker/scripts/build.mjs @@ -30,5 +30,18 @@ const build = async (file, opts) => { await terser(file); }; +// ENSURE MULTIPLE FEATURE NAMES ARE ALPHABETICALLY ORDERED FOR THE OUTPUT FILE await build('default', {}); -await build('tagged-events', { TAGGED_EVENTS: true }); +await build('click-events', { + DATA_ATTRIBUTES: true, + CLICK_EVENTS: true, +}); +await build('page-events', { + DATA_ATTRIBUTES: true, + PAGE_EVENTS: true, +}); +await build('click-events.page-events', { + DATA_ATTRIBUTES: true, + PAGE_EVENTS: true, + CLICK_EVENTS: true, +}); diff --git a/tracker/src/tracker.js b/tracker/src/tracker.js index ade2b753..209aeae1 100644 --- a/tracker/src/tracker.js +++ b/tracker/src/tracker.js @@ -9,6 +9,7 @@ * @property {boolean} p If the user is unique or not. * @property {boolean} q If this is the first time the user has visited this specific page. * @property {string} t Timezone of the user. + * @property {Object=} d Event custom properties. */ /** @@ -131,12 +132,12 @@ /** * @this {History} * @param {*} _state - The state object. - * @param {string} _unused - The title (unused). + * @param {string} _title - The title. * @param {(string | URL)=} url - The URL to navigate to. * @returns {void} */ ) => - function (_state, _unused, url) { + function (_state, _title, url) { if (url && location.pathname !== new URL(url, location.href).pathname) { sendUnloadBeacon(); // If the event is a history change, then we need to reset the id and timers @@ -149,6 +150,25 @@ } }; + // @ifdef DATA_ATTRIBUTES + /** + * Extracts key-value pairs from a given data attribute. + * @param {Element} target The target element from which to extract data. + * @param {string} attrName The name of the data attribute to extract (e.g., 'data-m:click'). + * @returns {Object} An object containing key-value pairs from the attribute. + */ + const extractDataAttributes = (target, attrName) => + (target.getAttribute(`data-m:${attrName}`) || '') + .split(';') // Split the attribute value into individual key-value pairs. + .reduce((acc, pair) => { + // Split each pair by '=' and trim whitespace. + const [k, v] = pair.split('=').map((s) => s.trim()); + // If both key and value exist, add them to the accumulator object. + if (k && v) acc[k] = v; + return acc; + }, {}); + // @endif + /** * Ping the server with the cache endpoint and read the last modified header to determine * if the user is unique or not. @@ -208,6 +228,16 @@ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#return_value */ "t": Intl.DateTimeFormat().resolvedOptions().timeZone, + // @ifdef PAGE_EVENTS + // Helper function to extract data attributes and merge them. + "d": [...document.querySelectorAll('[data-m\\:load]')].reduce( + (acc, elem) => ({ + ...acc, + ...extractDataAttributes(elem, 'load'), + }), + {}, + ), + // @endif }), ), // Will make the response opaque, but we don't need it. @@ -251,35 +281,7 @@ isUnloadCalled = true; }; - // @ifdef TAGGED_EVENTS - /** - * Send a custom beacon event to the server. - * @param {Object.} properties Event custom properties. - * @returns {void} - */ - const sendCustomBeacon = (properties) => { - // We use fetch here because it is more reliable than XHR. - fetch(host + 'event/hit', { - method: 'POST', - body: JSON.stringify( - // biome-ignore format: We use string literals for the keys to tell Closure Compiler to not rename them. - /** - * Payload to send to the server. - * @type {CustomPayload} - */ ({ - "b": uid, - "e": "custom", - "g": location.hostname, - "d": properties, - }), - ), - // Will make the response opaque, but we don't need it. - mode: 'no-cors', - }); - }; - // @endif - - // @ifdef TAGGED_EVENTS + // @ifdef CLICK_EVENTS /** * Click event listener to track custom events. * @param {MouseEvent} event The click event. @@ -290,23 +292,38 @@ // If the target is not an HTMLElement, then bail out. if (event.button > 1 || !(event.target instanceof HTMLElement)) return; - // Extract all data-medama-* attributes and send them as custom properties. - /** @type {Object} */ - const data = {}; - for (const attr of event.target.attributes) { - if (attr.name.startsWith('data-medama-')) - data[attr.name.slice(12)] = attr.value; - } + // Extract all data-m:click attributes and send them as custom properties. + const data = extractDataAttributes(event.target, 'click'); if (Object.keys(data).length > 0) { - sendCustomBeacon(data); + // We use fetch here because it is more reliable than XHR. + fetch(host + 'event/hit', { + method: 'POST', + body: JSON.stringify( + // biome-ignore format: We use string literals for the keys to tell Closure Compiler to not rename them. + /** + * Payload to send to the server. + * @type {CustomPayload} + */ ({ + "b": uid, + "e": "custom", + "g": location.hostname, + "d": data, + }), + ), + // Will make the response opaque, but we don't need it. + mode: 'no-cors', + }); } }; - // Click event listener only listens to primary left clicks. - addEventListener('click', clickTracker); - // Auxclick event listener listens to middle clicks and right clicks. - addEventListener('auxclick', clickTracker); + // Add event listeners to all elements with data-m:click attributes. + for (const elem of document.querySelectorAll('[data-m\\:click]')) { + // Click event listener only listens to primary left clicks. + elem.addEventListener('click', clickTracker); + // Auxclick event listener listens to middle clicks and right clicks. + elem.addEventListener('auxclick', clickTracker); + } // @endif // Prefer pagehide if available because it's more reliable than unload. diff --git a/tracker/tests/fixtures/history/index.html b/tracker/tests/fixtures/history/index.html index 27cec48c..574fabe2 100644 --- a/tracker/tests/fixtures/history/index.html +++ b/tracker/tests/fixtures/history/index.html @@ -1,63 +1,55 @@ - - - History API Router Example - - -

History API Router Example

- -
- -
-
- - - -
- - + + - window.addEventListener("popstate", router); - router(); // Initial load - - - diff --git a/tracker/tests/fixtures/serve.js b/tracker/tests/fixtures/serve.js index fe033998..738ccedd 100644 --- a/tracker/tests/fixtures/serve.js +++ b/tracker/tests/fixtures/serve.js @@ -26,7 +26,7 @@ Bun.serve({ if (url.pathname === '/script.js') { console.log('Serving:', url.pathname); return new Response( - Bun.file(__dirname + '/../../dist/tagged-events.min.js'), + Bun.file(__dirname + '/../../dist/click-events.page-events.min.js'), ); } diff --git a/tracker/tests/fixtures/simple/about.html b/tracker/tests/fixtures/simple/about.html index 2c01f327..a690056a 100644 --- a/tracker/tests/fixtures/simple/about.html +++ b/tracker/tests/fixtures/simple/about.html @@ -1,22 +1,26 @@ - - - Normal Webpage - - -

Normal Webpage

- -
-

This is the home page.

- - - -
- - + + + + Normal Webpage + + + +

Normal Webpage

+ +
+

This is the home page.

+ + + +
+ + + diff --git a/tracker/tests/fixtures/simple/contact.html b/tracker/tests/fixtures/simple/contact.html index 2c01f327..a690056a 100644 --- a/tracker/tests/fixtures/simple/contact.html +++ b/tracker/tests/fixtures/simple/contact.html @@ -1,22 +1,26 @@ - - - Normal Webpage - - -

Normal Webpage

- -
-

This is the home page.

- - - -
- - + + + + Normal Webpage + + + +

Normal Webpage

+ +
+

This is the home page.

+ + + +
+ + + diff --git a/tracker/tests/fixtures/simple/index.html b/tracker/tests/fixtures/simple/index.html index 2c01f327..a690056a 100644 --- a/tracker/tests/fixtures/simple/index.html +++ b/tracker/tests/fixtures/simple/index.html @@ -1,22 +1,26 @@ - - - Normal Webpage - - -

Normal Webpage

- -
-

This is the home page.

- - - -
- - + + + + Normal Webpage + + + +

Normal Webpage

+ +
+

This is the home page.

+ + + +
+ + + diff --git a/tracker/tests/helpers/tagged-events.js b/tracker/tests/helpers/click-events.js similarity index 88% rename from tracker/tests/helpers/tagged-events.js rename to tracker/tests/helpers/click-events.js index 21f3f5d4..4f3d01cf 100644 --- a/tracker/tests/helpers/tagged-events.js +++ b/tracker/tests/helpers/click-events.js @@ -7,7 +7,7 @@ import { addRequestListeners, createURL, matchRequests } from './helpers'; * * @param {import('./helpers').Tests} name */ -const taggedEventTests = (name) => { +const clickEventTests = (name) => { test.describe('button click', () => { test('click/auxclick event with data attribute', async ({ page }) => { const expectedRequests = [ @@ -18,8 +18,10 @@ const taggedEventTests = (name) => { postData: { e: 'custom', d: { + action: 'button', button: 'left', }, + g: 'localhost', }, }, { @@ -29,8 +31,10 @@ const taggedEventTests = (name) => { postData: { e: 'custom', d: { + action: 'button', button: 'middle', }, + g: 'localhost', }, }, ]; @@ -51,4 +55,4 @@ const taggedEventTests = (name) => { }); }; -export { taggedEventTests }; +export { clickEventTests }; diff --git a/tracker/tests/helpers/helpers.js b/tracker/tests/helpers/helpers.js index de756526..f05ffd1b 100644 --- a/tracker/tests/helpers/helpers.js +++ b/tracker/tests/helpers/helpers.js @@ -1,7 +1,7 @@ // @ts-check import { expect, test } from '@playwright/test'; -import { pageTests } from './page'; -import { taggedEventTests } from './tagged-events'; +import { loadUnloadTests } from './load-unload'; +import { clickEventTests } from './click-events'; /** * @typedef {('simple'|'history')} Tests @@ -208,12 +208,12 @@ const createURL = (name, path, relative = true) => * @param {Tests} name */ const createTests = (name) => { - test.describe(name + ' page tests', () => { - pageTests(name); + test.describe(name + ' load + unload tests', () => { + loadUnloadTests(name); }); - test.describe(name + ' tagged event tests', () => { - taggedEventTests(name); + test.describe(name + ' click event tests', () => { + clickEventTests(name); }); }; diff --git a/tracker/tests/helpers/page.js b/tracker/tests/helpers/load-unload.js similarity index 89% rename from tracker/tests/helpers/page.js rename to tracker/tests/helpers/load-unload.js index dbdfa9fe..df479ee8 100644 --- a/tracker/tests/helpers/page.js +++ b/tracker/tests/helpers/load-unload.js @@ -8,11 +8,11 @@ import { } from './helpers'; /** - * Create test block for all page loading related tests. + * Create test block for all page loading related tests. This also includes data-m:load custom properties. * * @param {import('./helpers').Tests} name */ -const pageTests = (name) => { +const loadUnloadTests = (name) => { test.describe('load', () => { test('unique visitor load event', async ({ page }) => { const expectedRequests = [ @@ -38,6 +38,11 @@ const pageTests = (name) => { r: '', p: true, q: true, + d: { + load: 'test', + load2: 'test2', + load3: 'test2', + }, }, }, ]; @@ -83,6 +88,11 @@ const pageTests = (name) => { r: '', p: false, // Returning visitor q: false, // Not a new page view + d: { + load: 'test', + load2: 'test2', + load3: 'test2', + }, }, }, ]; @@ -125,6 +135,11 @@ const pageTests = (name) => { u: createURL(name, 'about', false), p: false, // Returning visitor q: true, // New page view + d: { + load: 'test', + load2: 'test2', + load3: 'test2', + }, }, }, ]; @@ -169,6 +184,11 @@ const pageTests = (name) => { u: createURL(name, 'index', false), p: false, // Returning visitor q: false, // Returning page view + d: { + load: 'test', + load2: 'test2', + load3: 'test2', + }, }, ignoreBrowsers: ['webkit'], }, @@ -186,6 +206,11 @@ const pageTests = (name) => { u: createURL(name, 'index', false), p: name == 'simple' ? true : false, // Returning visitor q: false, // Returning page view + d: { + load: 'test', + load2: 'test2', + load3: 'test2', + }, }, ignoreBrowsers: ['firefox', 'chrome', 'msedge', 'chromium'], }, @@ -237,6 +262,11 @@ const pageTests = (name) => { u: createURL(name, 'index', false), p: false, // Returning visitor q: false, // Returning page view + d: { + load: 'test', + load2: 'test2', + load3: 'test2', + }, }, ignoreBrowsers: ['webkit'], }, @@ -254,6 +284,11 @@ const pageTests = (name) => { u: createURL(name, 'index', false), p: name == 'simple' ? true : false, // Returning visitor q: false, // Returning page view + d: { + load: 'test', + load2: 'test2', + load3: 'test2', + }, }, ignoreBrowsers: ['firefox', 'chrome', 'msedge', 'chromium'], }, @@ -280,4 +315,4 @@ const pageTests = (name) => { }); }; -export { pageTests }; +export { loadUnloadTests };