Skip to content

Commit

Permalink
Process signals that occur before init (#1116)
Browse files Browse the repository at this point in the history
  • Loading branch information
silesky authored Jul 22, 2024
1 parent a9251f0 commit 0fdf170
Show file tree
Hide file tree
Showing 11 changed files with 111 additions and 19 deletions.
5 changes: 5 additions & 0 deletions .changeset/pretty-clouds-occur.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@segment/analytics-signals': patch
---

Process signals that occur before analytics init
6 changes: 1 addition & 5 deletions packages/signals/signals-example/src/lib/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@
// You only want to instantiate SignalsPlugin in a browser context, otherwise you'll get an error.

import { AnalyticsBrowser } from '@segment/analytics-next'
import {
SignalsPlugin,
ProcessSignal,
} from '@segment/analytics-signals'
import { SignalsPlugin, ProcessSignal } from '@segment/analytics-signals'

export const analytics = new AnalyticsBrowser()
if (!process.env.WRITEKEY) {
Expand Down Expand Up @@ -49,7 +46,6 @@ export const loadAnalytics = () =>
...(isStage ? { cdnURL: 'https://cdn.segment.build' } : {}),
},
{
initialPageview: true,
...(isStage
? {
integrations: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,14 @@ export class BasePage {
this.edgeFn = edgeFn
await this.setupMockedRoutes()
await this.page.goto(this.url)
// expect analytics to be loaded
await Promise.all([
}

/**
* Wait for analytics and signals to be initialized
* Signals can be captured before this, so it's useful to have this method
*/
async waitForAnalyticsInit() {
return Promise.all([
this.waitForCDNSettingsResponse(),
this.waitForEdgeFunctionResponse(),
])
Expand Down
2 changes: 2 additions & 0 deletions packages/signals/signals-integration-tests/src/shims.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { AnalyticsBrowser } from '@segment/analytics-next'
import type { SignalsPlugin } from '@segment/analytics-signals'

declare global {
interface Window {
analytics: AnalyticsBrowser
signalsPlugin: SignalsPlugin
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const indexPage = new IndexPage()

const normalizeSnapshotEvent = (el: SegmentEvent) => {
return {
type: el.properties?.type,
type: el.type,
event: el.event,
userId: el.userId,
groupId: el.groupId,
Expand Down Expand Up @@ -38,21 +38,60 @@ test('Segment events', async ({ page }) => {
const basicEdgeFn = `
// this is a process signal function
const processSignal = (signal) => {
analytics.identify('john', { found: true })
analytics.group('foo', { hello: 'world' })
analytics.alias('john', 'johnsmith')
analytics.track('a track call', {foo: 'bar'})
analytics.page('Retail Page', 'Home', { url: 'http://my-home.com', title: 'Some Title' });
if (signal.type === 'interaction' && signal.data.eventType === 'click') {
analytics.identify('john', { found: true })
analytics.group('foo', { hello: 'world' })
analytics.alias('john', 'johnsmith')
analytics.track('a track call', {foo: 'bar'})
analytics.page('Retail Page', 'Home', { url: 'http://my-home.com', title: 'Some Title' });
}
}`

await indexPage.load(page, basicEdgeFn)
await indexPage.clickButton()
await Promise.all([
indexPage.clickButton(),
indexPage.waitForSignalsApiFlush(),
indexPage.waitForTrackingApiFlush(),
])

const trackingApiReqs = indexPage.trackingApiReqs.map(normalizeSnapshotEvent)

expect(trackingApiReqs).toEqual(snapshot)
})

test('Should dispatch events from signals that occurred before analytics was instantiated', async ({
page,
}) => {
const edgeFn = `
const processSignal = (signal) => {
if (signal.type === 'navigation' && signal.data.action === 'pageLoad') {
analytics.page('dispatched from signals - navigation')
}
if (signal.type === 'userDefined') {
analytics.track('dispatched from signals - userDefined')
}
}`

await indexPage.load(page, edgeFn)

// add a user defined signal before analytics is instantiated
void indexPage.addUserDefinedSignal()

await indexPage.waitForAnalyticsInit()

await Promise.all([
indexPage.waitForSignalsApiFlush(),
indexPage.waitForTrackingApiFlush(),
])
const trackingApiReqs = indexPage.trackingApiReqs
expect(trackingApiReqs).toHaveLength(2)

const pageEvents = trackingApiReqs.find((el) => el.type === 'page')!
expect(pageEvents).toBeTruthy()
expect(pageEvents.name).toEqual('dispatched from signals - navigation')

const userDefinedEvents = trackingApiReqs.find((el) => el.type === 'track')!
expect(userDefinedEvents).toBeTruthy()
expect(userDefinedEvents.event).toEqual(
'dispatched from signals - userDefined'
)
})
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const basicEdgeFn = `

test.beforeEach(async ({ page }) => {
await indexPage.load(page, basicEdgeFn)
await indexPage.waitForAnalyticsInit()
})

test('network signals', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,25 @@ export class IndexPage extends BasePage {
return promiseTimeout(p, 2000, 'analytics.on("page") did not resolve')
}

async makeAnalyticsTrackCall(): Promise<unknown> {
const p = this.page.evaluate(() => {
void window.analytics.track('some event')
return new Promise((resolve) => window.analytics.on('track', resolve))
})
return promiseTimeout(p, 2000, 'analytics.on("track") did not resolve')
}

addUserDefinedSignal() {
return this.page.evaluate(() => {
window.signalsPlugin.addSignal({
type: 'userDefined',
data: {
foo: 'bar',
},
})
})
}

async mockRandomJSONApi() {
await this.page.route('http://localhost:5432/api/foo', (route) => {
return route.fulfill({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const signalsPlugin = new SignalsPlugin({
disableSignalsRedaction: true,
})

;(window as any).signalsPlugin = signalsPlugin

analytics.load({
writeKey: '<SOME_WRITE_KEY>',
plugins: [signalsPlugin],
Expand Down
4 changes: 1 addition & 3 deletions packages/signals/signals/src/core/processor/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,12 @@ export class SignalEventProcessor {

async process(signal: Signal, signals: Signal[]) {
const analyticsMethodCalls = await this.sandbox.process(signal, signals)
logger.debug('New signal processed. Analytics method calls:', {
methodArgs: analyticsMethodCalls,
})

for (const methodName in analyticsMethodCalls) {
const name = methodName as MethodName
const eventsCollection = analyticsMethodCalls[name]
eventsCollection.forEach((args) => {
logger.debug(`analytics.${name}(...) called with args`, args)
// @ts-ignore
this.analytics[name](...args)
})
Expand Down
1 change: 0 additions & 1 deletion packages/signals/signals/src/core/processor/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,6 @@ export class Sandbox {
await this.jsSandbox.run(code, scope)

const calls = analytics.getCalls()
logger.debug('analytics calls', calls)
return calls
}
}
25 changes: 25 additions & 0 deletions packages/signals/signals/src/core/signals/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { AnalyticsService } from '../analytics-service'
import { SignalEventProcessor } from '../processor/processor'
import { Sandbox, SandboxSettings } from '../processor/sandbox'
import { SignalGlobalSettings, SignalsSettingsConfig } from './settings'
import { logger } from '../../lib/logger'

interface ISignals {
start(analytics: AnyAnalytics): Promise<void>
Expand All @@ -29,6 +30,7 @@ export type SignalsPublicEmitterContract = {

export class Signals implements ISignals {
private buffer: SignalBuffer
private preStartBuffer: Signal[] = []
public signalEmitter: SignalEmitter
private cleanup: VoidFunction[] = []
private signalsClient: SignalsIngestClient
Expand All @@ -47,9 +49,30 @@ export class Signals implements ISignals {
void this.buffer.add(signal)
})

this.signalEmitter.subscribe(this.addToPreStartBuffer)

void this.registerGenerator([...domGenerators, NetworkGenerator])
}

private addToPreStartBuffer = (signal: Signal) => {
this.preStartBuffer.push(signal)
}

/**
* Flush/process any signals that were emitted before the start method was called.
*/
private flushPreStartBuffer = (processor: SignalEventProcessor) => {
logger.debug(
`Flushing ${this.preStartBuffer.length} events in pre-start buffer`,
this.preStartBuffer
)
this.signalEmitter.unsubscribe(this.addToPreStartBuffer)
this.preStartBuffer.forEach(async (signal) => {
void processor.process(signal, await this.buffer.getAll())
})
this.preStartBuffer = []
}

/**
* Does the following:
* - Sends any queued signals to the server.
Expand All @@ -72,6 +95,8 @@ export class Signals implements ISignals {
sandbox
)

void this.flushPreStartBuffer(processor)

this.signalEmitter.subscribe(async (signal) => {
void processor.process(signal, await this.buffer.getAll())
})
Expand Down

0 comments on commit 0fdf170

Please sign in to comment.