-
Notifications
You must be signed in to change notification settings - Fork 2
feat: [EXA-1487] node-view event tracking
#282
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
Samuel Durkin (FailedSitcom)
wants to merge
18
commits into
main
Choose a base branch
from
EXA-1487-extend-sdk-to-send-ex-o-enriched-events
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
28ef3fc
feat(api-schemas): add sourceMap and exo_view schemas
FailedSitcom 0a52df0
feat(core-sdk): add trackNodeView and node-view event builder
FailedSitcom 2a73cb1
feat(web-sdk): add automatic exo_view runtime and payload resolution
FailedSitcom a77d528
feat(api-schemas): add anonymousId to NodeViewEvent
FailedSitcom aa9c02a
feat(core-sdk): add NodeViewTrackingArgs and derive anonymousId in tr…
FailedSitcom 3ec336b
feat(web-sdk): add configurable node view auto-tracking controls
FailedSitcom 4a3e7cc
feat(web-sdk): propagate full node metadata through dataset attributes
FailedSitcom e234240
feat(react-web-sdk): add useOptimizedNode hook
FailedSitcom 31be7e0
fix(react-web-sdk): clear stale node tracking attributes
FailedSitcom 563ebfd
feat(react-web-sdk): stamp full node metadata in useOptimizedNode
FailedSitcom 5ee64ba
refactor(web-sdk): remove autoTrackNodeViews in favour of autoTrackNo…
FailedSitcom bc512d0
refactor: improve readibility of `useOptimizedNode`
FailedSitcom 1b14810
refactor: improve `createNodeViewDetector` readiblility
FailedSitcom 7b4208a
refactor: improve readability of `refactor: improve readability of `r…
FailedSitcom ccc091a
Drop layers, change variant to variantId
bosunski cabdb96
Handle unavailable localStorage in web SDK
bosunski e8eb445
EXA-1487 Emit node view variant index
bosunski 030c58a
EXA-1487 Prefer source map variant metadata
bosunski File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
75 changes: 75 additions & 0 deletions
75
packages/universal/api-schemas/src/experience/sourceMap/SourceMap.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| import { describe, expect, it } from '@rstest/core' | ||
| import { SourceMap } from './SourceMap' | ||
|
|
||
| const VALID_SOURCE_MAP = { | ||
| variants: [ | ||
| { type: 'personalization', id: 'default' }, | ||
| { type: 'personalization', id: 'variant-a' }, | ||
| ], | ||
| layers: [ | ||
| { kind: 'Experience', id: 'exp-id', variants: [1] }, | ||
| { kind: 'Fragment', id: 'frag-id' }, | ||
| ], | ||
| nodes: { | ||
| 'node-1': { layers: [0], scope: 0 }, | ||
| 'node-2': { layers: [1, 0], scope: 1 }, | ||
| }, | ||
| } | ||
|
|
||
| describe('SourceMap schema', () => { | ||
| it('accepts a valid sourceMap', () => { | ||
| const result = SourceMap.safeParse(VALID_SOURCE_MAP) | ||
|
|
||
| expect(result.success).toBe(true) | ||
| }) | ||
|
|
||
| it('accepts explicit optimization metadata on variants', () => { | ||
| const result = SourceMap.safeParse({ | ||
| ...VALID_SOURCE_MAP, | ||
| variants: [ | ||
| { type: 'personalization', id: 'default' }, | ||
| { | ||
| type: 'personalization', | ||
| id: 'variant-a', | ||
| experienceId: 'exp-id', | ||
| optimizationId: 'opt-id', | ||
| variantId: 'variant-a', | ||
| variantIndex: 1, | ||
| }, | ||
| ], | ||
| }) | ||
|
|
||
| expect(result.success).toBe(true) | ||
| }) | ||
|
|
||
| it('accepts layers without variants', () => { | ||
| const result = SourceMap.safeParse({ | ||
| ...VALID_SOURCE_MAP, | ||
| layers: [{ kind: 'Fragment', id: 'frag-id' }], | ||
| }) | ||
|
|
||
| expect(result.success).toBe(true) | ||
| }) | ||
|
|
||
| it('rejects missing nodes map', () => { | ||
| const { nodes: _removed, ...withoutNodes } = VALID_SOURCE_MAP | ||
| const result = SourceMap.safeParse(withoutNodes) | ||
|
|
||
| expect(result.success).toBe(false) | ||
| }) | ||
|
|
||
| it('rejects node missing scope', () => { | ||
| const result = SourceMap.safeParse({ | ||
| ...VALID_SOURCE_MAP, | ||
| nodes: { 'node-1': { layers: [0] } }, | ||
| }) | ||
|
|
||
| expect(result.success).toBe(false) | ||
| }) | ||
|
|
||
| it('accepts an empty variants array', () => { | ||
| const result = SourceMap.safeParse({ ...VALID_SOURCE_MAP, variants: [] }) | ||
|
|
||
| expect(result.success).toBe(true) | ||
| }) | ||
| }) |
133 changes: 133 additions & 0 deletions
133
packages/universal/api-schemas/src/experience/sourceMap/SourceMap.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| import * as z from 'zod/mini' | ||
|
|
||
| /** | ||
| * A single variant entry from the XDA `extensions.sourceMap.variants` array. | ||
| * | ||
| * @public | ||
| */ | ||
| export const SourceMapVariant = z.object({ | ||
| /** | ||
| * Variant category, e.g. `'personalization'`. | ||
| */ | ||
| type: z.string(), | ||
| /** | ||
| * Variant identifier, e.g. `'default'` or a variant sys.id. | ||
| */ | ||
| id: z.string(), | ||
| /** | ||
| * Contentful Experience `sys.id` associated with this variant. | ||
| */ | ||
| experienceId: z.optional(z.string()), | ||
| /** | ||
| * Ninetailed optimization ID associated with this variant. | ||
| */ | ||
| optimizationId: z.optional(z.string()), | ||
| /** | ||
| * Selected variant identifier associated with this variant. | ||
| */ | ||
| variantId: z.optional(z.string()), | ||
| /** | ||
| * Selected variant index associated with this variant. | ||
| */ | ||
| variantIndex: z.optional(z.number()), | ||
| }) | ||
|
|
||
| /** | ||
| * TypeScript type inferred from {@link SourceMapVariant}. | ||
| * | ||
| * @public | ||
| */ | ||
| export type SourceMapVariant = z.infer<typeof SourceMapVariant> | ||
|
|
||
| /** | ||
| * A structural layer from the XDA `extensions.sourceMap.layers` array. | ||
| * | ||
| * @public | ||
| */ | ||
| export const SourceMapLayer = z.object({ | ||
| /** | ||
| * Structural kind of the layer. | ||
| * | ||
| * @remarks | ||
| * Possible values include `'Experience'`, `'Fragment'`, | ||
| * `'InlineFragment'`, and `'InlineComponent'`. | ||
| */ | ||
| kind: z.string(), | ||
| /** | ||
| * Contentful sys.id of the Experience or Fragment entry this layer | ||
| * represents. | ||
| */ | ||
| id: z.string(), | ||
| /** | ||
| * Optional indices into `SourceMap.variants[]` that apply to this layer. | ||
| * | ||
| * @remarks | ||
| * Present only on layers that correspond to an optimization target. | ||
| */ | ||
| variants: z.optional(z.array(z.number())), | ||
| }) | ||
|
|
||
| /** | ||
| * TypeScript type inferred from {@link SourceMapLayer}. | ||
| * | ||
| * @public | ||
| */ | ||
| export type SourceMapLayer = z.infer<typeof SourceMapLayer> | ||
|
|
||
| /** | ||
| * Metadata for a single rendered node from the XDA | ||
| * `extensions.sourceMap.nodes` map. | ||
| * | ||
| * @public | ||
| */ | ||
| export const SourceMapNode = z.object({ | ||
| /** | ||
| * Leaf-to-root indices into `SourceMap.layers[]` for this node. | ||
| */ | ||
| layers: z.array(z.number()), | ||
| /** | ||
| * Index of the nearest ancestor Fragment or Experience layer in | ||
| * `SourceMap.layers[]`. | ||
| */ | ||
| scope: z.number(), | ||
| }) | ||
|
|
||
| /** | ||
| * TypeScript type inferred from {@link SourceMapNode}. | ||
| * | ||
| * @public | ||
| */ | ||
| export type SourceMapNode = z.infer<typeof SourceMapNode> | ||
|
|
||
| /** | ||
| * Zod schema for the `extensions.sourceMap` object returned in XDA | ||
| * responses. | ||
| * | ||
| * @remarks | ||
| * The sourceMap provides structural context for each rendered node, | ||
| * enabling the SDK to resolve entity identity and variant selection | ||
| * without additional server round-trips. | ||
| * | ||
| * @public | ||
| */ | ||
| export const SourceMap = z.object({ | ||
| /** | ||
| * Flat list of variant entries referenced by layers. | ||
| */ | ||
| variants: z.array(SourceMapVariant), | ||
| /** | ||
| * Flat list of structural layers ordered leaf-to-root. | ||
| */ | ||
| layers: z.array(SourceMapLayer), | ||
| /** | ||
| * Map from rendered node ID to node metadata. | ||
| */ | ||
| nodes: z.record(z.string(), SourceMapNode), | ||
| }) | ||
|
|
||
| /** | ||
| * TypeScript type inferred from {@link SourceMap}. | ||
| * | ||
| * @public | ||
| */ | ||
| export type SourceMap = z.infer<typeof SourceMap> |
1 change: 1 addition & 0 deletions
1
packages/universal/api-schemas/src/experience/sourceMap/index.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export * from './SourceMap' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
78 changes: 78 additions & 0 deletions
78
packages/universal/api-schemas/src/insights/event/NodeViewEvent.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| import { describe, expect, it } from '@rstest/core' | ||
| import { InsightsEvent } from './InsightsEvent' | ||
| import { NodeViewEvent } from './NodeViewEvent' | ||
|
|
||
| const BASE_UNIVERSAL = { | ||
| channel: 'web' as const, | ||
| context: { | ||
| campaign: {}, | ||
| gdpr: { isConsentGiven: true }, | ||
| library: { name: 'test', version: '0.0.0' }, | ||
| locale: 'en-US', | ||
| }, | ||
| messageId: 'msg-1', | ||
| originalTimestamp: '2024-01-01T00:00:00.000Z', | ||
| sentAt: '2024-01-01T00:00:00.000Z', | ||
| timestamp: '2024-01-01T00:00:00.000Z', | ||
| } | ||
|
|
||
| const VALID_NODE_VIEW = { | ||
| ...BASE_UNIVERSAL, | ||
| anonymousId: 'anon-id', | ||
| type: 'exo_node_view' as const, | ||
| entityId: 'exp-sys-id', | ||
| entityKind: 'Experience' as const, | ||
| variantId: 'variant-a', | ||
| variantIndex: 1, | ||
| optimizationId: 'opt-id', | ||
| viewId: 'view-uuid', | ||
| viewDurationMs: 1500, | ||
| } | ||
|
|
||
| describe('NodeViewEvent schema', () => { | ||
| it('accepts a valid payload', () => { | ||
| const result = NodeViewEvent.safeParse(VALID_NODE_VIEW) | ||
|
|
||
| expect(result.success).toBe(true) | ||
| }) | ||
|
|
||
| it('accepts all valid entityKind values', () => { | ||
| const kinds = ['Experience', 'Fragment', 'InlineFragment', 'InlineComponent'] as const | ||
|
|
||
| for (const entityKind of kinds) { | ||
| const result = NodeViewEvent.safeParse({ ...VALID_NODE_VIEW, entityKind }) | ||
|
|
||
| expect(result.success, `entityKind=${entityKind}`).toBe(true) | ||
| } | ||
| }) | ||
|
|
||
| it('rejects an unknown entityKind', () => { | ||
| const result = NodeViewEvent.safeParse({ ...VALID_NODE_VIEW, entityKind: 'Unknown' }) | ||
|
|
||
| expect(result.success).toBe(false) | ||
| }) | ||
|
|
||
| it('rejects a missing required field', () => { | ||
| const { entityId: _removed, ...withoutEntityId } = VALID_NODE_VIEW | ||
| const result = NodeViewEvent.safeParse(withoutEntityId) | ||
|
|
||
| expect(result.success).toBe(false) | ||
| }) | ||
|
|
||
| it('rejects a non-number viewDurationMs', () => { | ||
| const result = NodeViewEvent.safeParse({ ...VALID_NODE_VIEW, viewDurationMs: 'long' }) | ||
|
|
||
| expect(result.success).toBe(false) | ||
| }) | ||
| }) | ||
|
|
||
| describe('InsightsEvent discriminated union', () => { | ||
| it('discriminates exo_node_view correctly', () => { | ||
| const result = InsightsEvent.safeParse(VALID_NODE_VIEW) | ||
|
|
||
| expect(result.success).toBe(true) | ||
| if (result.success) { | ||
| expect(result.data.type).toBe('exo_node_view') | ||
| } | ||
| }) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
pnpm help cleandoesn't have-ror--parallel