Refactor analytics event tracking and improve documentation

- Removed unused browserSessionId from client-side and server-side event tracking to streamline the analytics process.
- Updated the `trackEvent` method to ensure immediate flushing of events, enhancing reliability in event delivery.
- Improved documentation for `trackEvent` usage, clarifying the behavior of fire-and-forget calls and the importance of flushing analytics.

These changes optimize event handling and enhance the clarity of the analytics framework documentation.
This commit is contained in:
mantrakp04 2026-03-25 16:50:15 -07:00
parent 327470fde7
commit b84d8a769b
7 changed files with 109 additions and 95 deletions

View File

@ -282,7 +282,6 @@ it("should allow explicit session replay linkage from the client app", async ({
amount: 4200,
currency: "usd",
}, {
browserSessionId,
sessionReplayId,
sessionReplaySegmentId,
});
@ -370,14 +369,10 @@ it("should track project-scoped and request-bound custom analytics events from t
await serverApp.trackEvent(explicitLinkedEventType, {
source: "cron",
}, {
browserSessionId: explicitBrowserSessionId,
sessionReplayId: explicitSessionReplayId,
sessionReplaySegmentId: explicitSessionReplaySegmentId,
});
// Server trackEvent is now non-blocking; flush to ensure events are sent before querying
await serverApp.flushAnalytics();
const requestBoundResponse = await queryAnalyticsByEventTypeWithRetry(adminApp, requestBoundEventType);
expect(requestBoundResponse.result[0]).toMatchObject({
event_type: requestBoundEventType,

View File

@ -34,10 +34,10 @@ When replay recording is enabled in the browser SDK, client-side `trackEvent()`
### Client-side (all frameworks)
Call `trackEvent()` directly on your `StackClientApp` instance:
Call `trackEvent()` directly on your `StackClientApp` instance. The returned promise resolves once the event has been delivered:
```ts
app.trackEvent("upgrade.clicked", {
await app.trackEvent("upgrade.clicked", {
plan: "pro",
source: "pricing-page",
});
@ -48,14 +48,30 @@ app.trackEvent("upgrade.clicked", {
Call `trackEvent()` on your `StackServerApp` instance, passing the request for user attribution:
```ts
serverApp.trackEvent("support.ticket.created", {
await serverApp.trackEvent("support.ticket.created", {
channel: "chat",
}, req);
```
Any Fetch API `Request`, Express-style `req`, or Hono `c.req.raw` works as the 3rd argument.
Server `trackEvent()` is non-blocking — it buffers events and flushes them in the background. Call `await serverApp.flushAnalytics()` if you need to ensure all events are sent before the process exits.
### Fire-and-forget
If you don't need confirmation that the event was delivered, simply don't `await` the call. Internally events are still batched — non-awaited calls that happen close together will be sent in the same HTTP request:
```ts
// Fire-and-forget — events are batched and sent in the background
serverApp.trackEvent("page.viewed", { path: "/pricing" }, req);
serverApp.trackEvent("feature.used", { name: "export" }, req);
```
You can also use `flushAnalytics()` to wait for all pending analytics (including auto-captured events) to be delivered at once — useful before a process exits or a serverless function returns:
```ts
serverApp.trackEvent("job.finished", { duration });
serverApp.trackEvent("job.result", { status: "ok" });
await serverApp.flushAnalytics();
```
### Serverless environments

View File

@ -8,8 +8,6 @@ export type AnalyticsReplayLinkOptions = {
sessionReplaySegmentId?: string | null,
};
export const autoCapturedAnalyticsEventTypes = AUTO_CAPTURED_ANALYTICS_EVENT_TYPES;
const autoCapturedAnalyticsEventTypeSet = new Set<string>(AUTO_CAPTURED_ANALYTICS_EVENT_TYPES);
export function assertValidAnalyticsEventName(

View File

@ -2212,6 +2212,11 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
if (this._eventTracker) {
this._eventTracker.trackEvent(eventType, data, options);
// Flush immediately so the returned promise resolves only after the event
// has been delivered. The buffer is still useful for fire-and-forget callers
// (captureException, auto-captured events, non-awaited trackEvent calls)
// that rely on the background 10s flush.
await this.flushAnalytics();
return;
}

View File

@ -158,10 +158,23 @@ export class EventTracker {
trackEvent(eventType: string, data: Record<string, unknown> = {}, options?: { at?: Date | number } & AnalyticsReplayLinkOptions) {
assertValidAnalyticsEventName(eventType);
const defaultReplayLinkOptions = this._getDefaultReplayLinkOptions();
this._enqueueEvent(eventType, data, {
at: options?.at,
sessionReplayId: options?.sessionReplayId,
sessionReplaySegmentId: options?.sessionReplaySegmentId,
});
}
private _pushAutoCapturedEvent(eventType: AutoCapturedAnalyticsEventType, data: Record<string, unknown>) {
assertValidAnalyticsEventName(eventType, { allowAutoCapturedReservedType: true });
this._enqueueEvent(eventType, data);
}
private _enqueueEvent(eventType: string, data: Record<string, unknown>, options?: { at?: Date | number } & AnalyticsReplayLinkOptions) {
const defaults = this._getDefaultReplayLinkOptions();
const replayLinkOptions = normalizeAnalyticsReplayLinkOptions({
sessionReplayId: options?.sessionReplayId ?? defaultReplayLinkOptions.sessionReplayId,
sessionReplaySegmentId: options?.sessionReplaySegmentId ?? defaultReplayLinkOptions.sessionReplaySegmentId,
sessionReplayId: options?.sessionReplayId ?? defaults.sessionReplayId,
sessionReplaySegmentId: options?.sessionReplaySegmentId ?? defaults.sessionReplaySegmentId,
});
const segmentSpanId = replayLinkOptions.session_replay_segment_id;
const activeSpan = getActiveSpan();
@ -169,23 +182,18 @@ export class EventTracker {
...(activeSpan ? [activeSpan.spanId] : []),
...(segmentSpanId ? [segmentSpanId] : []),
];
this._pushEvent({
const superProps = this._deps.getSuperProperties?.() ?? {};
const normalizedData = normalizeAnalyticsEventPayload(data);
const event: AnalyticsBatchEvent = {
event_type: eventType,
event_id: generateUuid(),
// only set trace_id when the event is inside an active span; standalone events don't need one
// only set trace_id when inside an active span
trace_id: activeSpan?.traceId ?? undefined,
event_at_ms: normalizeAnalyticsEventAt(options?.at),
...(parentSpanIds.length > 0 ? { parent_span_ids: parentSpanIds } : {}),
data: normalizeAnalyticsEventPayload(data),
data: Object.keys(superProps).length > 0 ? { ...superProps, ...normalizedData } : normalizedData,
...replayLinkOptions,
});
}
private _pushEvent(event: AnalyticsBatchEvent) {
const superProps = this._deps.getSuperProperties?.() ?? {};
if (Object.keys(superProps).length > 0) {
event = { ...event, data: { ...superProps, ...event.data } };
}
};
this._events.push(event);
this._approxBytes += JSON.stringify(event).length;
if (this._events.length >= MAX_EVENTS_PER_BATCH || this._approxBytes >= MAX_APPROX_BYTES_PER_BATCH) {
@ -193,27 +201,6 @@ export class EventTracker {
}
}
private _pushAutoCapturedEvent(eventType: AutoCapturedAnalyticsEventType, data: Record<string, unknown>) {
assertValidAnalyticsEventName(eventType, { allowAutoCapturedReservedType: true });
const replayLinkOptions = normalizeAnalyticsReplayLinkOptions(this._getDefaultReplayLinkOptions());
const segmentSpanId = replayLinkOptions.session_replay_segment_id;
const activeSpan = getActiveSpan();
const parentSpanIds = [
...(activeSpan ? [activeSpan.spanId] : []),
...(segmentSpanId ? [segmentSpanId] : []),
];
this._pushEvent({
event_type: eventType,
event_id: generateUuid(),
// only set trace_id when the event is inside an active span; standalone events don't need one
trace_id: activeSpan?.traceId ?? undefined,
event_at_ms: Date.now(),
...(parentSpanIds.length > 0 ? { parent_span_ids: parentSpanIds } : {}),
data: normalizeAnalyticsEventPayload(data),
...replayLinkOptions,
});
}
private _getDefaultReplayLinkOptions(): AnalyticsReplayLinkOptions {
const replayLinkOptions = this._deps.getReplayLinkOptions?.() ?? null;
return {

View File

@ -455,7 +455,13 @@ export class _StackServerAppImplIncomplete<HasTokenStore extends boolean, Projec
}
async trackEvent(eventType: string, data?: Record<string, unknown>, optionsOrRequest?: TrackServerAnalyticsEventOptions<HasTokenStore> | RequestLike | { headers: Record<string, string | string[] | undefined> }): Promise<void> {
this._trackEventInternal(eventType, data, optionsOrRequest, false);
await this._trackEventInternalAsync(eventType, data, optionsOrRequest, false);
// Flush immediately so the returned promise resolves only after the event
// has been delivered. This bypasses the batcher's interval/size triggers —
// the buffer is still useful for fire-and-forget callers (captureException,
// auto-captured events, non-awaited trackEvent calls) that rely on the
// background 10s flush.
await this.flushAnalytics();
}
captureException(
@ -527,6 +533,15 @@ export class _StackServerAppImplIncomplete<HasTokenStore extends boolean, Projec
optionsOrRequest?: TrackServerAnalyticsEventOptions<HasTokenStore> | RequestLike | { headers: Record<string, string | string[] | undefined> },
allowAutoCaptured?: boolean,
): void {
runAsynchronously(() => this._trackEventInternalAsync(eventType, data, optionsOrRequest, allowAutoCaptured));
}
private async _trackEventInternalAsync(
eventType: string,
data?: Record<string, unknown>,
optionsOrRequest?: TrackServerAnalyticsEventOptions<HasTokenStore> | RequestLike | { headers: Record<string, string | string[] | undefined> },
allowAutoCaptured?: boolean,
): Promise<void> {
assertValidAnalyticsEventName(eventType, { allowAutoCapturedReservedType: allowAutoCaptured });
const { options: rawOptions, headersSource } = this._normalizeRequestArg(
@ -550,29 +565,26 @@ export class _StackServerAppImplIncomplete<HasTokenStore extends boolean, Projec
sessionReplaySegmentId: options?.sessionReplaySegmentId,
});
const capturedOptions = options;
runAsynchronously(async () => {
const { session, userId, teamId } = await this._resolveUserContext(capturedOptions);
const { session, userId, teamId } = await this._resolveUserContext(options);
const segmentSpanId = replayLinkOptions.session_replay_segment_id;
const activeSpan = getActiveSpanFromTracing();
const parentSpanIds = [
...(activeSpan ? [activeSpan.spanId] : []),
...(segmentSpanId ? [segmentSpanId] : []),
];
this._serverEventBatcher.push({
event_type: eventType,
event_id: generateUuid(),
// only set trace_id when the event is inside an active span; standalone events don't need one
trace_id: activeSpan?.traceId ?? undefined,
event_at_ms: eventAtMs,
...(parentSpanIds.length > 0 ? { parent_span_ids: parentSpanIds } : {}),
data: eventData,
user_id: userId ?? undefined,
team_id: teamId ?? undefined,
...replayLinkOptions,
}, session);
});
const segmentSpanId = replayLinkOptions.session_replay_segment_id;
const activeSpan = getActiveSpanFromTracing();
const parentSpanIds = [
...(activeSpan ? [activeSpan.spanId] : []),
...(segmentSpanId ? [segmentSpanId] : []),
];
this._serverEventBatcher.push({
event_type: eventType,
event_id: generateUuid(),
// only set trace_id when the event is inside an active span; standalone events don't need one
trace_id: activeSpan?.traceId ?? undefined,
event_at_ms: eventAtMs,
...(parentSpanIds.length > 0 ? { parent_span_ids: parentSpanIds } : {}),
data: eventData,
user_id: userId ?? undefined,
team_id: teamId ?? undefined,
...replayLinkOptions,
}, session);
}
async flushAnalytics(): Promise<void> {

View File

@ -253,38 +253,39 @@ export function readHeader(headers: unknown, name: string): string | null {
return null;
}
function readJsonHeader<T extends Record<string, string | null>>(
headers: unknown,
name: string,
fields: (keyof T & string)[],
): T {
const result = Object.fromEntries(fields.map((f) => [f, null])) as T;
const raw = readHeader(headers, name);
if (!raw) return result;
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
for (const field of fields) {
if (typeof parsed[field] === "string") {
(result as any)[field] = parsed[field];
}
}
} catch {
// malformed header
}
return result;
}
export function serializeTraceContext(span: { traceId: string; spanId: string }): Record<string, string> {
return {
"x-stack-trace": JSON.stringify({ trace_id: span.traceId, span_id: span.spanId }),
};
}
export function extractTraceContext(headers: unknown): { traceId: string | null; parentSpanId: string | null } {
const raw = readHeader(headers, "x-stack-trace");
if (!raw) return { traceId: null, parentSpanId: null };
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
return {
traceId: typeof parsed.trace_id === "string" ? parsed.trace_id : null,
parentSpanId: typeof parsed.span_id === "string" ? parsed.span_id : null,
};
} catch {
return { traceId: null, parentSpanId: null };
}
export function extractTraceContext(headers: unknown) {
const { trace_id, span_id } = readJsonHeader(headers, "x-stack-trace", ["trace_id", "span_id"]);
return { traceId: trace_id, parentSpanId: span_id };
}
export function extractReplayLink(headers: unknown): { sessionReplayId: string | null; sessionReplaySegmentId: string | null } {
const raw = readHeader(headers, "x-stack-replay");
if (!raw) return { sessionReplayId: null, sessionReplaySegmentId: null };
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
return {
sessionReplayId: typeof parsed.session_replay_id === "string" ? parsed.session_replay_id : null,
sessionReplaySegmentId: typeof parsed.session_replay_segment_id === "string" ? parsed.session_replay_segment_id : null,
};
} catch {
return { sessionReplayId: null, sessionReplaySegmentId: null };
}
export function extractReplayLink(headers: unknown) {
const { session_replay_id, session_replay_segment_id } = readJsonHeader(headers, "x-stack-replay", ["session_replay_id", "session_replay_segment_id"]);
return { sessionReplayId: session_replay_id, sessionReplaySegmentId: session_replay_segment_id };
}