feat(analytics): public trackEvent/startSpan SDK + custom events/spans ingestion

Client SDK (browser):
- app.trackEvent(type, data?, {parentIds?}) and app.startSpan(type, {data?, parentIds?, startedAtMs?}) on StackClientApp
- Span handles: setData (merge + re-write), end (idempotent), trackEvent/startSpan (inherit full chain), ref() for cross-tier parenting
- setGlobalSpan/unsetGlobalSpan (ambient parents for spans AND events, auto-unset on end), app.flush()
- Per-item promises settle on batch ack; pre-caught (no unhandled rejections); nothing throws synchronously
- Spans are versioned upserts: written open on start, re-written on setData/end with per-handle monotonic updated_at_ms
- Batch-poisoning protection: full item validation at enqueue (name regex, 16KB data cap, uuid parents, depth<=10)
- clearBuffer rejects pending + inert-ifies live spans (sign-out privacy, pairs with segment-id rotation)

Server SDK:
- serverApp.trackEvent/startSpan with explicit {userId}; same-tick coalescing per userId (one POST per sync burst)
- sendAnalyticsEventBatchAsServer on HexclaveServerInterface (secret server key -> analytics base URL)

Backend:
- events batch route accepts custom event types, events[*].parent_span_ids, spans[], and server/admin auth with body user_id (verified in tenancy)
- buildCustomSpanRows: cs- prefixed ids, system ancestry (rti-/sri-/srsi-, same gating as events) prepended to client chain, version clamped to now+5min
- custom spans insert off the response path (waitUntil), quota debits events+spans
- stripLoneSurrogates moved to lib/clickhouse for reuse

Tests: 8 new spans.test.ts cases, 9 new event-tracker.test.ts cases, 17 new e2e cases (e2e not executed: dev stack down; 2 pre-existing snapshots updated for intentional schema-message changes)
This commit is contained in:
mantrakp04 2026-07-02 11:34:34 -07:00
parent ddec2a4ac4
commit b8215a8595
14 changed files with 2141 additions and 95 deletions

View File

@ -1,42 +1,35 @@
import { getClickhouseAdminClient } from "@/lib/clickhouse";
import { getClickhouseAdminClient, stripLoneSurrogates } from "@/lib/clickhouse";
import { arePlanLimitsEnforced, getBillingTeamId } from "@/lib/plan-entitlements";
import { findRecentSessionReplay } from "@/lib/session-replays";
import { buildEventSpanFields } from "@/lib/spans";
import { buildCustomSpanRows, buildEventSpanFields, insertSpans, toSpanId, SPAN_ID_PREFIXES } from "@/lib/spans";
import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks";
import { getHexclaveServerApp } from "@/hexclave";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@hexclave/shared";
import { ITEM_IDS } from "@hexclave/shared/dist/plans";
import { adaptSchema, clientOrHigherAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
import { StatusError } from "@hexclave/shared/dist/utils/errors";
import { captureError, StatusError } from "@hexclave/shared/dist/utils/errors";
import * as zlib from "node:zlib";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
// Custom (user-defined) event/span type names: must not start with `$` (reserved
// for system types), start with a letter, and stay within 64 chars. Keep in sync
// with the SDK-side validation in packages/template's event-tracker.
const CUSTOM_TELEMETRY_NAME_RE = /^[a-zA-Z][a-zA-Z0-9_.:-]{0,63}$/;
const SYSTEM_EVENT_TYPES = ["$page-view", "$click"] as const;
const MAX_EVENTS = 500;
const MAX_SPANS = 500;
const MAX_PARENT_CHAIN = 10;
const MAX_ITEM_DATA_BYTES = 16_000;
const MAX_COMPRESSED_BYTES = 1 * 1024 * 1024;
const MAX_DECOMPRESSED_BYTES = 8 * 1024 * 1024;
// Lone surrogates (\uD800-\uDFFF not part of a valid pair) are technically
// representable in JS strings but rejected by ClickHouse's JSON parser.
// The client-side event tracker can produce these when .substring() truncates
// text in the middle of a surrogate pair (e.g. emoji characters).
// eslint-disable-next-line no-control-regex
const LONE_SURROGATE_RE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
function stripLoneSurrogates(value: unknown): unknown {
if (typeof value === "string") {
return value.replace(LONE_SURROGATE_RE, "\uFFFD");
}
if (Array.isArray(value)) {
return value.map(stripLoneSurrogates);
}
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([k, v]) => [k, stripLoneSurrogates(v)])
);
}
return value;
function isPlainObjectWithinLimit(value: unknown): boolean {
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
return JSON.stringify(value).length <= MAX_ITEM_DATA_BYTES;
}
// Bodies sent as application/octet-stream are gzipped JSON. The encoding is
@ -70,8 +63,8 @@ function maybeDecodeBinaryBody(value: unknown): unknown {
export const POST = createSmartRouteHandler({
metadata: {
summary: "Upload analytics event batch",
description: "Uploads a batch of auto-captured analytics events ($page-view, $click).",
summary: "Upload analytics telemetry batch",
description: "Uploads a batch of analytics telemetry: auto-captured events ($page-view, $click), custom events, and custom spans.",
tags: ["Analytics Events"],
hidden: true,
},
@ -83,17 +76,61 @@ export const POST = createSmartRouteHandler({
refreshTokenId: adaptSchema,
}).defined(),
body: yupObject({
session_replay_segment_id: yupString().defined().matches(UUID_RE, "Invalid session_replay_segment_id"),
// Required for client auth (enforced in the handler — browser batches are
// always tied to a per-tab segment); optional for server/admin auth.
session_replay_segment_id: yupString().optional().matches(UUID_RE, "Invalid session_replay_segment_id"),
batch_id: yupString().defined().matches(UUID_RE, "Invalid batch_id"),
sent_at_ms: yupNumber().defined().integer().min(0),
// Server/admin auth only (enforced in the handler): attributes the batch
// to a user when there is no session access token to derive one from.
user_id: yupString().optional().matches(UUID_RE, "Invalid user_id"),
events: yupArray(
yupObject({
event_type: yupString().defined().oneOf(["$page-view", "$click"]),
event_type: yupString().defined().test(
"event-type",
`event_type must be one of ${SYSTEM_EVENT_TYPES.join(", ")} or a custom name matching ${CUSTOM_TELEMETRY_NAME_RE}`,
// yup skips tests for undefined values, so `value` is always set here.
(value) => (SYSTEM_EVENT_TYPES as readonly string[]).includes(value) || CUSTOM_TELEMETRY_NAME_RE.test(value),
),
event_at_ms: yupNumber().defined().integer().min(0),
data: yupMixed().defined(),
}).defined(),
).defined().min(1).max(MAX_EVENTS),
}).defined().transform((_value, originalValue) => maybeDecodeBinaryBody(originalValue)),
// Custom ancestor chain, root-first, raw span uuids. System ancestry
// (refresh-token/replay/segment) is composed server-side on top.
parent_span_ids: yupArray(yupString().defined().matches(UUID_RE, "Invalid parent span id")).optional().max(MAX_PARENT_CHAIN),
}).defined().test(
"custom-event-data",
`Custom event data must be a JSON object of at most ${MAX_ITEM_DATA_BYTES} serialized bytes`,
// System event data stays permissive for backward compatibility with
// deployed trackers; the object/size cap applies to custom types only.
(event) => (SYSTEM_EVENT_TYPES as readonly string[]).includes(event.event_type) || isPlainObjectWithinLimit(event.data),
),
).optional().max(MAX_EVENTS),
spans: yupArray(
yupObject({
span_id: yupString().defined().matches(UUID_RE, "Invalid span_id"),
// Custom names only — `$…` span types are reserved for system spans
// and can never be written through this endpoint.
span_type: yupString().defined().matches(CUSTOM_TELEMETRY_NAME_RE, "Invalid span_type"),
started_at_ms: yupNumber().defined().integer().min(0),
ended_at_ms: yupNumber().nullable().defined().integer().min(0),
parent_span_ids: yupArray(yupString().defined().matches(UUID_RE, "Invalid parent span id")).defined().max(MAX_PARENT_CHAIN),
data: yupMixed().defined().test(
"span-data",
`Span data must be a JSON object of at most ${MAX_ITEM_DATA_BYTES} serialized bytes`,
(value) => isPlainObjectWithinLimit(value),
),
updated_at_ms: yupNumber().defined().integer().min(0),
}).defined().test(
"span-interval",
"ended_at_ms must be greater than or equal to started_at_ms",
(span) => span.ended_at_ms == null || span.ended_at_ms >= span.started_at_ms,
),
).optional().max(MAX_SPANS),
}).defined().test(
"non-empty-batch",
"A batch must contain at least one event or span",
(body) => (body.events?.length ?? 0) + (body.spans?.length ?? 0) >= 1,
).transform((_value, originalValue) => maybeDecodeBinaryBody(originalValue)),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
@ -106,45 +143,84 @@ export const POST = createSmartRouteHandler({
if (!auth.tenancy.config.apps.installed["analytics"]?.enabled) {
throw new KnownErrors.AnalyticsNotEnabled();
}
if (!auth.user) {
throw new KnownErrors.UserAuthenticationRequired();
}
if (!auth.refreshTokenId) {
throw new StatusError(StatusError.BadRequest, "A refresh token is required for analytics events");
const events = body.events ?? [];
const spans = body.spans ?? [];
const tenancyId = auth.tenancy.id;
const prisma = await getPrismaClientForTenancy(auth.tenancy);
// Client auth is the browser tracker: identity comes from the session (user +
// refresh token, always present) and batches are tied to a per-tab segment.
// Server/admin auth has no session to derive from, so the caller attributes
// the batch explicitly via body.user_id (or not at all — project-level rows).
let userId: string | null;
let refreshTokenId: string | null;
if (auth.type === "client") {
if (!auth.user) {
throw new KnownErrors.UserAuthenticationRequired();
}
if (!auth.refreshTokenId) {
throw new StatusError(StatusError.BadRequest, "A refresh token is required for analytics events");
}
if (body.user_id != null) {
throw new StatusError(StatusError.BadRequest, "user_id must not be set with client auth; it is derived from the session");
}
if (body.session_replay_segment_id == null) {
throw new StatusError(StatusError.BadRequest, "session_replay_segment_id is required for analytics batches with client auth");
}
userId = auth.user.id;
refreshTokenId = auth.refreshTokenId;
} else {
if (body.user_id != null) {
const user = await prisma.projectUser.findUnique({
where: {
tenancyId_projectUserId: {
tenancyId,
projectUserId: body.user_id,
},
},
});
if (!user) {
throw new StatusError(StatusError.BadRequest, "user_id does not correspond to a user on this project/branch");
}
}
userId = body.user_id ?? auth.user?.id ?? null;
refreshTokenId = auth.refreshTokenId ?? null;
}
const projectId = auth.tenancy.project.id;
const branchId = auth.tenancy.branchId;
const userId = auth.user.id;
const refreshTokenId = auth.refreshTokenId;
const tenancyId = auth.tenancy.id;
const app = getHexclaveServerApp();
const billingTeamId = getBillingTeamId(auth.tenancy.project);
if (billingTeamId != null && arePlanLimitsEnforced()) {
const totalItems = events.length + spans.length;
const eventsItem = await app.getItem({ itemId: ITEM_IDS.analyticsEvents, teamId: billingTeamId });
const isDebited = await eventsItem.tryDecreaseQuantity(body.events.length);
const isDebited = await eventsItem.tryDecreaseQuantity(totalItems);
if (!isDebited) {
throw new KnownErrors.ItemQuantityInsufficientAmount(ITEM_IDS.analyticsEvents, billingTeamId, body.events.length);
throw new KnownErrors.ItemQuantityInsufficientAmount(ITEM_IDS.analyticsEvents, billingTeamId, totalItems);
}
}
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const recentSession = await findRecentSessionReplay(prisma, { tenancyId, refreshTokenId });
const recentSession = refreshTokenId == null ? null : await findRecentSessionReplay(prisma, { tenancyId, refreshTokenId });
const sessionReplayId = recentSession?.id ?? null;
const sessionReplaySegmentId = body.session_replay_segment_id ?? null;
const clickhouseClient = getClickhouseAdminClient();
// Point each event at its ancestor spans (root-first: refresh-token, replay,
// then the per-tab span when a replay exists). The per-tab id itself already
// lives in the session_replay_segment_id column — see lib/spans.tsx.
// lives in the session_replay_segment_id column — see lib/spans.tsx. The
// client-supplied custom chain (raw uuids) is appended after the system
// ancestry, each id prefixed cs-.
const eventSpanFields = buildEventSpanFields({
sessionReplayId: recentSession?.id ?? null,
sessionReplaySegmentId: body.session_replay_segment_id,
sessionReplayId,
sessionReplaySegmentId,
refreshTokenId,
});
const rows = body.events.map((event) => ({
const rows = events.map((event) => ({
event_type: event.event_type,
event_at: new Date(event.event_at_ms),
data: stripLoneSurrogates(event.data),
@ -153,25 +229,54 @@ export const POST = createSmartRouteHandler({
user_id: userId,
team_id: null,
refresh_token_id: refreshTokenId,
session_replay_id: recentSession?.id ?? null,
session_replay_segment_id: body.session_replay_segment_id,
...eventSpanFields,
session_replay_id: sessionReplayId,
session_replay_segment_id: sessionReplaySegmentId,
parent_span_ids: [
...eventSpanFields.parent_span_ids,
...(event.parent_span_ids ?? []).map((id) => toSpanId(SPAN_ID_PREFIXES.custom, id)),
],
}));
await clickhouseClient.insert({
table: "analytics_internal.events",
values: rows,
format: "JSONEachRow",
clickhouse_settings: {
date_time_input_format: "best_effort",
async_insert: 1,
},
});
if (rows.length > 0) {
await clickhouseClient.insert({
table: "analytics_internal.events",
values: rows,
format: "JSONEachRow",
clickhouse_settings: {
date_time_input_format: "best_effort",
async_insert: 1,
},
});
}
// Custom spans are versioned upserts into analytics_internal.spans. Written
// off the response path (same pattern as the replay batch route) so a slow or
// unavailable ClickHouse never delays the upload; the events insert above
// stays on-path because the response reports what was accepted.
if (spans.length > 0) {
const spanRows = buildCustomSpanRows({
spans,
projectId,
branchId,
userId,
refreshTokenId,
sessionReplayId,
sessionReplaySegmentId,
serverNowMs: Date.now(),
});
runAsynchronouslyAndWaitUntil(async () => {
try {
await insertSpans(clickhouseClient, spanRows);
} catch (error) {
captureError("analytics-custom-spans-insert", error);
}
});
}
return {
statusCode: 200,
bodyType: "json",
body: { inserted: body.events.length },
body: { inserted: events.length + spans.length },
};
},
});

View File

@ -7,6 +7,28 @@ import { HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors";
// dependency on the @clickhouse/client package.
export type { ClickHouseClient } from "@clickhouse/client";
// Lone surrogates (\uD800-\uDFFF not part of a valid pair) are technically
// representable in JS strings but rejected by ClickHouse's JSON parser.
// The client-side event tracker can produce these when .substring() truncates
// text in the middle of a surrogate pair (e.g. emoji characters).
// eslint-disable-next-line no-control-regex
const LONE_SURROGATE_RE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
export function stripLoneSurrogates(value: unknown): unknown {
if (typeof value === "string") {
return value.replace(LONE_SURROGATE_RE, "<22>");
}
if (Array.isArray(value)) {
return value.map(stripLoneSurrogates);
}
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([k, v]) => [k, stripLoneSurrogates(v)])
);
}
return value;
}
function getAdminAuth() {
return {
username: getEnvVariable("STACK_CLICKHOUSE_ADMIN_USER", "stackframe"),

View File

@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { SPAN_ID_PREFIXES, buildEventSpanFields, insertSessionReplaySpans, toSpanId } from "./spans";
import { SPAN_ID_PREFIXES, buildCustomSpanRows, buildEventSpanFields, insertSessionReplaySpans, toSpanId } from "./spans";
describe("toSpanId", () => {
it("prefixes raw ids without touching the raw value", () => {
@ -103,3 +103,121 @@ describe("insertSessionReplaySpans", () => {
expect(client.insert).not.toHaveBeenCalled();
});
});
describe("buildCustomSpanRows", () => {
const NOW_MS = new Date("2026-01-01T00:10:00.000Z").getTime();
const baseOpts = {
projectId: "p1",
branchId: "b1",
userId: "user1",
refreshTokenId: "rt1",
sessionReplayId: "replay1",
sessionReplaySegmentId: "seg1",
serverNowMs: NOW_MS,
};
const baseSpan = {
span_id: "0f000000-0000-4000-8000-000000000001",
span_type: "checkout-flow",
started_at_ms: NOW_MS - 60_000,
ended_at_ms: null,
parent_span_ids: [] as string[],
data: {},
updated_at_ms: NOW_MS - 60_000,
};
it("prefixes the span id and every client parent with cs- and prepends the full system ancestry root-first", () => {
const rows = buildCustomSpanRows({
...baseOpts,
spans: [{
...baseSpan,
parent_span_ids: ["0f000000-0000-4000-8000-00000000aaaa", "0f000000-0000-4000-8000-00000000bbbb"],
}],
});
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe(`cs-${baseSpan.span_id}`);
expect(rows[0].parent_span_ids).toEqual([
"rti-rt1",
"sri-replay1",
"srsi-seg1",
"cs-0f000000-0000-4000-8000-00000000aaaa",
"cs-0f000000-0000-4000-8000-00000000bbbb",
]);
});
it("degrades the system ancestry with the same gating as event rows (refresh-token only, then none)", () => {
const refreshTokenOnly = buildCustomSpanRows({
...baseOpts,
sessionReplayId: null,
sessionReplaySegmentId: null,
spans: [baseSpan],
});
expect(refreshTokenOnly[0].parent_span_ids).toEqual(["rti-rt1"]);
const serverAuth = buildCustomSpanRows({
...baseOpts,
userId: null,
refreshTokenId: null,
sessionReplayId: null,
sessionReplaySegmentId: null,
spans: [baseSpan],
});
expect(serverAuth[0].parent_span_ids).toEqual([]);
expect(serverAuth[0].user_id).toBeNull();
expect(serverAuth[0].refresh_token_id).toBeNull();
});
it("keeps open intervals open and fills identity columns raw (unprefixed)", () => {
const rows = buildCustomSpanRows({ ...baseOpts, spans: [baseSpan] });
expect(rows[0]).toMatchObject({
span_type: "checkout-flow",
span_ended_at: null,
project_id: "p1",
branch_id: "b1",
user_id: "user1",
team_id: null,
refresh_token_id: "rt1",
session_replay_id: "replay1",
session_replay_segment_id: "seg1",
});
expect(rows[0].span_started_at).toEqual(new Date(baseSpan.started_at_ms));
});
it("uses the client updated_at_ms as the version, clamped to [1, now + 5min]", () => {
const rows = buildCustomSpanRows({
...baseOpts,
spans: [
{ ...baseSpan, updated_at_ms: NOW_MS - 1_000 },
{ ...baseSpan, span_id: "0f000000-0000-4000-8000-000000000002", updated_at_ms: 0 },
{ ...baseSpan, span_id: "0f000000-0000-4000-8000-000000000003", updated_at_ms: NOW_MS + 60 * 60 * 1000 },
],
});
expect(rows[0].version).toBe(NOW_MS - 1_000);
expect(rows[1].version).toBe(1);
expect(rows[2].version).toBe(NOW_MS + 5 * 60 * 1000);
});
it("serializes data to a JSON string and strips lone surrogates (ClickHouse rejects them)", () => {
const rows = buildCustomSpanRows({
...baseOpts,
spans: [{ ...baseSpan, data: { label: "truncated \uD83D", count: 3 } }],
});
expect(rows[0].data).toBe(JSON.stringify({ label: "truncated <20>", count: 3 }));
});
it("closes the interval when ended_at_ms is set", () => {
const endedAtMs = NOW_MS - 30_000;
const rows = buildCustomSpanRows({
...baseOpts,
spans: [{ ...baseSpan, ended_at_ms: endedAtMs, updated_at_ms: endedAtMs }],
});
expect(rows[0].span_ended_at).toEqual(new Date(endedAtMs));
expect(rows[0].version).toBe(endedAtMs);
});
it("refuses $-prefixed span types even if a caller bypasses the route schema", () => {
expect(() => buildCustomSpanRows({
...baseOpts,
spans: [{ ...baseSpan, span_type: "$session-replay" }],
})).toThrowError(/must not start with "\$"/);
});
});

View File

@ -1,4 +1,5 @@
import type { ClickHouseClient } from "./clickhouse";
import { HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors";
import { stripLoneSurrogates, type ClickHouseClient } from "./clickhouse";
/**
* Spans are telemetry facts (time intervals) the sibling of events. They are
@ -24,6 +25,10 @@ export const SPAN_ID_PREFIXES = {
team: "ti-",
project: "pi-",
branch: "bi-",
// User-defined spans created via the SDK's startSpan(). Applied server-side to
// the client-generated raw uuid and to every id inside a client-supplied parent
// chain — clients only ever transmit raw custom uuids, never prefixed ids.
custom: "cs-",
} as const;
export const SPAN_TYPES = {
@ -102,6 +107,83 @@ export async function insertSpans(client: ClickHouseClient, rows: SpanInsertRow[
});
}
/**
* One custom span as it arrives on the wire from the SDK (inside the analytics
* events batch). Ids are raw uuids; `parent_span_ids` is the client's CUSTOM
* ancestor chain only (root-first) system ancestry is composed server-side.
*/
export type CustomSpanWireItem = {
span_id: string,
span_type: string,
started_at_ms: number,
ended_at_ms: number | null,
parent_span_ids: string[],
data: unknown,
updated_at_ms: number,
};
// How far into the future a client-supplied `updated_at_ms` may run before we
// clamp it. A skewed clock only corrupts ordering among that user's own span
// re-writes; the clamp just bounds how long a bogus future version could mask
// legitimate later updates.
const CUSTOM_SPAN_VERSION_MAX_FUTURE_MS = 5 * 60 * 1000;
/**
* Builds `analytics_internal.spans` rows for SDK-created custom spans. Each
* row's `parent_span_ids` is the server-known system ancestry (same gating as
* event rows see `buildEventSpanFields`) followed by the client's custom
* chain, every custom id prefixed `cs-`. The version is the client's
* `updated_at_ms` per-span monotonic by SDK construction, so the row carrying
* the latest update wins in the ReplacingMergeTree regardless of insert order
* (an end row can never be shadowed by a late-arriving open row). The replay
* spans' end-time-as-version scheme is unusable here: two data re-writes while
* the span is still open would collide at the same version.
*/
export function buildCustomSpanRows(opts: {
spans: CustomSpanWireItem[],
projectId: string,
branchId: string,
userId: string | null,
refreshTokenId: string | null,
sessionReplayId: string | null,
sessionReplaySegmentId: string | null,
serverNowMs: number,
}): SpanInsertRow[] {
const systemAncestry = buildEventSpanFields({
sessionReplayId: opts.sessionReplayId,
sessionReplaySegmentId: opts.sessionReplaySegmentId,
refreshTokenId: opts.refreshTokenId,
}).parent_span_ids;
return opts.spans.map((span) => {
// The route schema is the primary gate; this backstop keeps the invariant
// even for future callers: `$…` span types are reserved for system spans
// and must never be writable through the custom-span path.
if (span.span_type.startsWith("$")) {
throw new HexclaveAssertionError(`Custom span types must not start with "$". Received: ${JSON.stringify(span.span_type)}`);
}
return {
id: toSpanId(SPAN_ID_PREFIXES.custom, span.span_id),
span_type: span.span_type,
span_started_at: new Date(span.started_at_ms),
span_ended_at: span.ended_at_ms == null ? null : new Date(span.ended_at_ms),
parent_span_ids: [
...systemAncestry,
...span.parent_span_ids.map((id) => toSpanId(SPAN_ID_PREFIXES.custom, id)),
],
data: JSON.stringify(stripLoneSurrogates(span.data)),
project_id: opts.projectId,
branch_id: opts.branchId,
user_id: opts.userId,
team_id: null,
refresh_token_id: opts.refreshTokenId,
session_replay_id: opts.sessionReplayId,
session_replay_segment_id: opts.sessionReplaySegmentId,
version: Math.min(Math.max(span.updated_at_ms, 1), opts.serverNowMs + CUSTOM_SPAN_VERSION_MAX_FUTURE_MS),
};
});
}
/**
* Emits the two spans that describe a session replay from the replay batch route:
* the replay-level `$session-replay` span and the per-tab `$session-replay-segment`

View File

@ -343,12 +343,12 @@ it("rejects empty events array", async ({ expect }) => {
"details": {
"message": deindent\`
Request validation failed on POST /api/v1/analytics/events/batch:
- body.events field must have at least 1 items
- A batch must contain at least one event or span
\`,
},
"error": deindent\`
Request validation failed on POST /api/v1/analytics/events/batch:
- body.events field must have at least 1 items
- A batch must contain at least one event or span
\`,
},
"headers": Headers {
@ -505,12 +505,12 @@ it("rejects invalid event_type", async ({ expect }) => {
"details": {
"message": deindent\`
Request validation failed on POST /api/v1/analytics/events/batch:
- body.events[0].event_type must be one of the following values: $page-view, $click
- event_type must be one of $page-view, $click or a custom name matching /^[a-zA-Z][a-zA-Z0-9_.:-]{0,63}$/
\`,
},
"error": deindent\`
Request validation failed on POST /api/v1/analytics/events/batch:
- body.events[0].event_type must be one of the following values: $page-view, $click
- event_type must be one of $page-view, $click or a custom name matching /^[a-zA-Z][a-zA-Z0-9_.:-]{0,63}$/
\`,
},
"headers": Headers {
@ -770,3 +770,515 @@ it("team plan starts with correct analytics event allocation", async ({ expect }
const quantity = await getItemQuantity(ownerTeamId, ITEM_IDS.analyticsEvents);
expect(quantity).toBe(PLAN_LIMITS.team.analyticsEvents);
});
// ============================================================================
// Custom events & custom spans
// ============================================================================
async function setupAnalyticsProject() {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } });
}
async function uploadTelemetryBatch(
body: {
session_replay_segment_id?: string,
batch_id?: string,
sent_at_ms?: number,
user_id?: string,
events?: unknown[],
spans?: unknown[],
},
options?: { accessType?: "client" | "server" },
) {
return await niceBackendFetch("/api/v1/analytics/events/batch", {
method: "POST",
accessType: options?.accessType ?? "client",
body: {
batch_id: randomUUID(),
sent_at_ms: Date.now(),
...body,
},
});
}
function makeCustomSpan(overrides?: Record<string, unknown>) {
const now = Date.now();
return {
span_id: randomUUID(),
span_type: "checkout-flow",
started_at_ms: now - 1000,
ended_at_ms: null,
parent_span_ids: [],
data: {},
updated_at_ms: now,
...overrides,
};
}
// Retry query because both the events insert (async_insert) and the spans
// insert (written off the response path via waitUntil) land with a delay.
async function queryAnalyticsUntil(
body: { query: string, params?: Record<string, string> },
isDone: (res: { status: number, body: any }) => boolean,
attempts = 20,
) {
let queryRes;
for (let attempt = 0; attempt < attempts; attempt++) {
await wait(500);
queryRes = await niceBackendFetch("/api/v1/internal/analytics/query", {
method: "POST",
accessType: "admin",
body,
});
if (queryRes.status === 200 && isDone(queryRes)) {
break;
}
}
return queryRes;
}
it("accepts custom events and stamps system ancestry on them", async ({ expect }) => {
await setupAnalyticsProject();
await Auth.Otp.signIn();
const sessionReplaySegmentId = randomUUID();
const now = Date.now();
const res = await uploadTelemetryBatch({
session_replay_segment_id: sessionReplaySegmentId,
events: [{ event_type: "checkout_completed", event_at_ms: now - 100, data: { cart_size: 3 } }],
});
expect(res).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "inserted": 1 },
"headers": Headers { <some fields may have been hidden> },
}
`);
const queryRes = await queryAnalyticsUntil({
query: "SELECT event_type, parent_span_ids FROM events WHERE session_replay_segment_id = {segId:String}",
params: { segId: sessionReplaySegmentId },
}, (r) => r.body?.result?.length === 1);
expect(queryRes?.status).toBe(200);
const row = (queryRes?.body as any).result[0];
expect(row.event_type).toBe("checkout_completed");
// No active session replay, so the only system ancestor is the refresh-token span.
expect(row.parent_span_ids).toHaveLength(1);
expect(row.parent_span_ids[0]).toMatch(/^rti-/);
});
it("appends the client-supplied custom parent chain after system ancestry on events", async ({ expect }) => {
await setupAnalyticsProject();
await Auth.Otp.signIn();
const sessionReplaySegmentId = randomUUID();
const parentSpanId = randomUUID();
const now = Date.now();
const res = await uploadTelemetryBatch({
session_replay_segment_id: sessionReplaySegmentId,
events: [{
event_type: "checkout_step",
event_at_ms: now - 100,
data: { step: 1 },
parent_span_ids: [parentSpanId],
}],
});
expect(res.status).toBe(200);
expect(res.body.inserted).toBe(1);
const queryRes = await queryAnalyticsUntil({
query: "SELECT parent_span_ids FROM events WHERE session_replay_segment_id = {segId:String}",
params: { segId: sessionReplaySegmentId },
}, (r) => r.body?.result?.length === 1);
expect(queryRes?.status).toBe(200);
const row = (queryRes?.body as any).result[0];
// Root-first: system ancestry (refresh-token) first, then the cs-prefixed custom chain.
expect(row.parent_span_ids).toHaveLength(2);
expect(row.parent_span_ids[0]).toMatch(/^rti-/);
expect(row.parent_span_ids[1]).toBe(`cs-${parentSpanId}`);
});
it("rejects unknown $-prefixed event types", async ({ expect }) => {
await setupAnalyticsProject();
await Auth.Otp.signIn();
const res = await uploadTelemetryBatch({
session_replay_segment_id: randomUUID(),
events: [{ event_type: "$custom-fake", event_at_ms: Date.now(), data: {} }],
});
expect(res.status).toBe(400);
expect(res.body?.code).toBe("SCHEMA_ERROR");
expect(res.body?.error).toContain("event_type must be one of");
});
it("rejects custom event types that do not start with a letter", async ({ expect }) => {
await setupAnalyticsProject();
await Auth.Otp.signIn();
const res = await uploadTelemetryBatch({
session_replay_segment_id: randomUUID(),
events: [{ event_type: "1bad", event_at_ms: Date.now(), data: {} }],
});
expect(res.status).toBe(400);
expect(res.body?.code).toBe("SCHEMA_ERROR");
expect(res.body?.error).toContain("event_type must be one of");
});
it("rejects custom event types longer than 64 characters", async ({ expect }) => {
await setupAnalyticsProject();
await Auth.Otp.signIn();
const res = await uploadTelemetryBatch({
session_replay_segment_id: randomUUID(),
events: [{ event_type: "a".repeat(65), event_at_ms: Date.now(), data: {} }],
});
expect(res.status).toBe(400);
expect(res.body?.code).toBe("SCHEMA_ERROR");
expect(res.body?.error).toContain("event_type must be one of");
});
it("rejects custom event data that is not a plain object", async ({ expect }) => {
await setupAnalyticsProject();
await Auth.Otp.signIn();
const res = await uploadTelemetryBatch({
session_replay_segment_id: randomUUID(),
events: [{ event_type: "checkout_completed", event_at_ms: Date.now(), data: [1, 2, 3] }],
});
expect(res.status).toBe(400);
expect(res.body?.code).toBe("SCHEMA_ERROR");
expect(res.body?.error).toContain("Custom event data must be a JSON object");
});
it("rejects custom event data larger than the serialized size cap", async ({ expect }) => {
await setupAnalyticsProject();
await Auth.Otp.signIn();
const res = await uploadTelemetryBatch({
session_replay_segment_id: randomUUID(),
events: [{ event_type: "checkout_completed", event_at_ms: Date.now(), data: { pad: "x".repeat(16_001) } }],
});
expect(res.status).toBe(400);
expect(res.body?.code).toBe("SCHEMA_ERROR");
expect(res.body?.error).toContain("Custom event data must be a JSON object");
});
it("rejects $-prefixed span types", async ({ expect }) => {
await setupAnalyticsProject();
await Auth.Otp.signIn();
const res = await uploadTelemetryBatch({
session_replay_segment_id: randomUUID(),
spans: [makeCustomSpan({ span_type: "$reserved" })],
});
expect(res.status).toBe(400);
expect(res.body?.code).toBe("SCHEMA_ERROR");
expect(res.body?.error).toContain("Invalid span_type");
});
it("rejects spans that end before they start", async ({ expect }) => {
await setupAnalyticsProject();
await Auth.Otp.signIn();
const now = Date.now();
const res = await uploadTelemetryBatch({
session_replay_segment_id: randomUUID(),
spans: [makeCustomSpan({ started_at_ms: now, ended_at_ms: now - 1000 })],
});
expect(res.status).toBe(400);
expect(res.body?.code).toBe("SCHEMA_ERROR");
expect(res.body?.error).toContain("ended_at_ms must be greater than or equal to started_at_ms");
});
it("rejects a batch with neither events nor spans", async ({ expect }) => {
await setupAnalyticsProject();
await Auth.Otp.signIn();
const res = await uploadTelemetryBatch({
session_replay_segment_id: randomUUID(),
});
expect(res.status).toBe(400);
expect(res.body?.code).toBe("SCHEMA_ERROR");
expect(res.body?.error).toContain("A batch must contain at least one event or span");
});
it("rejects user_id with client auth", async ({ expect }) => {
await setupAnalyticsProject();
const { userId } = await Auth.Otp.signIn();
const res = await uploadTelemetryBatch({
session_replay_segment_id: randomUUID(),
user_id: userId,
events: [{ event_type: "checkout_completed", event_at_ms: Date.now(), data: {} }],
});
expect(res.status).toBe(400);
expect(res.body).toBe("user_id must not be set with client auth; it is derived from the session");
});
it("rejects client-auth batches without session_replay_segment_id", async ({ expect }) => {
await setupAnalyticsProject();
await Auth.Otp.signIn();
const res = await uploadTelemetryBatch({
events: [{ event_type: "checkout_completed", event_at_ms: Date.now(), data: {} }],
});
expect(res.status).toBe(400);
expect(res.body).toBe("session_replay_segment_id is required for analytics batches with client auth");
});
it("accepts a spans-only batch and lands it on the spans surface", async ({ expect }) => {
await setupAnalyticsProject();
const { userId } = await Auth.Otp.signIn();
const sessionReplaySegmentId = randomUUID();
const spanId = randomUUID();
const now = Date.now();
const res = await uploadTelemetryBatch({
session_replay_segment_id: sessionReplaySegmentId,
spans: [{
span_id: spanId,
span_type: "checkout-flow",
started_at_ms: now - 1000,
ended_at_ms: null,
parent_span_ids: [],
data: {},
updated_at_ms: now,
}],
});
expect(res).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "inserted": 1 },
"headers": Headers { <some fields may have been hidden> },
}
`);
const queryRes = await queryAnalyticsUntil({
query: "SELECT id, span_type, span_ended_at, parent_span_ids, user_id, session_replay_segment_id FROM spans WHERE id = {id:String}",
params: { id: `cs-${spanId}` },
}, (r) => r.body?.result?.length === 1);
expect(queryRes?.status).toBe(200);
const rows = (queryRes?.body as any).result;
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe(`cs-${spanId}`);
expect(rows[0].span_type).toBe("checkout-flow");
// The span is still open.
expect(rows[0].span_ended_at).toBeNull();
// No active session replay, so the only system ancestor is the refresh-token span.
expect(rows[0].parent_span_ids).toHaveLength(1);
expect(rows[0].parent_span_ids[0]).toMatch(/^rti-/);
expect(rows[0].user_id).toBe(userId);
expect(rows[0].session_replay_segment_id).toBe(sessionReplaySegmentId);
});
it("collapses open→closed span re-writes to the ended row", async ({ expect }) => {
await setupAnalyticsProject();
await Auth.Otp.signIn();
const sessionReplaySegmentId = randomUUID();
const spanId = randomUUID();
const now = Date.now();
const startedAtMs = now - 10_000;
const endedAtMs = now - 2000;
const openUpdatedAtMs = now - 9000;
const closedUpdatedAtMs = now - 1000;
const openRes = await uploadTelemetryBatch({
session_replay_segment_id: sessionReplaySegmentId,
spans: [makeCustomSpan({ span_id: spanId, started_at_ms: startedAtMs, ended_at_ms: null, updated_at_ms: openUpdatedAtMs })],
});
expect(openRes.status).toBe(200);
const openQueryRes = await queryAnalyticsUntil({
query: "SELECT id, span_ended_at FROM spans WHERE id = {id:String}",
params: { id: `cs-${spanId}` },
}, (r) => r.body?.result?.length === 1);
expect(openQueryRes?.status).toBe(200);
expect((openQueryRes?.body as any).result[0].span_ended_at).toBeNull();
const closedRes = await uploadTelemetryBatch({
session_replay_segment_id: sessionReplaySegmentId,
spans: [makeCustomSpan({ span_id: spanId, started_at_ms: startedAtMs, ended_at_ms: endedAtMs, updated_at_ms: closedUpdatedAtMs })],
});
expect(closedRes.status).toBe(200);
// The view reads FINAL, so the two versions collapse to the ended row.
const closedQueryRes = await queryAnalyticsUntil({
query: "SELECT id, span_ended_at FROM spans WHERE id = {id:String}",
params: { id: `cs-${spanId}` },
}, (r) => r.body?.result?.length === 1 && r.body.result[0].span_ended_at != null);
expect(closedQueryRes?.status).toBe(200);
const rows = (closedQueryRes?.body as any).result;
expect(rows).toHaveLength(1);
expect(rows[0].span_ended_at).not.toBeNull();
});
it("keeps the ended row when an open re-write arrives with an older version (out-of-order)", async ({ expect }) => {
await setupAnalyticsProject();
await Auth.Otp.signIn();
const sessionReplaySegmentId = randomUUID();
const spanId = randomUUID();
const sentinelSpanId = randomUUID();
const now = Date.now();
const startedAtMs = now - 10_000;
const endedAtMs = now - 2000;
const openUpdatedAtMs = now - 9000;
const closedUpdatedAtMs = now - 1000;
// The END row arrives first, carrying the LATER version…
const closedRes = await uploadTelemetryBatch({
session_replay_segment_id: sessionReplaySegmentId,
spans: [makeCustomSpan({ span_id: spanId, started_at_ms: startedAtMs, ended_at_ms: endedAtMs, updated_at_ms: closedUpdatedAtMs })],
});
expect(closedRes.status).toBe(200);
const closedQueryRes = await queryAnalyticsUntil({
query: "SELECT id, span_ended_at FROM spans WHERE id = {id:String}",
params: { id: `cs-${spanId}` },
}, (r) => r.body?.result?.length === 1);
expect(closedQueryRes?.status).toBe(200);
expect((closedQueryRes?.body as any).result[0].span_ended_at).not.toBeNull();
// …then a stale OPEN row with the EARLIER version arrives. The sentinel span
// rides in the same batch (same ClickHouse insert), so once it is visible the
// stale open row has landed too.
const staleOpenRes = await uploadTelemetryBatch({
session_replay_segment_id: sessionReplaySegmentId,
spans: [
makeCustomSpan({ span_id: spanId, started_at_ms: startedAtMs, ended_at_ms: null, updated_at_ms: openUpdatedAtMs }),
makeCustomSpan({ span_id: sentinelSpanId }),
],
});
expect(staleOpenRes.status).toBe(200);
const sentinelQueryRes = await queryAnalyticsUntil({
query: "SELECT id FROM spans WHERE id = {id:String}",
params: { id: `cs-${sentinelSpanId}` },
}, (r) => r.body?.result?.length === 1);
expect(sentinelQueryRes?.status).toBe(200);
// The ended row still wins: version (updated_at_ms) decides, not insert order.
const finalQueryRes = await queryAnalyticsUntil({
query: "SELECT id, span_ended_at FROM spans WHERE id = {id:String}",
params: { id: `cs-${spanId}` },
}, (r) => r.body?.result?.length === 1);
expect(finalQueryRes?.status).toBe(200);
const rows = (finalQueryRes?.body as any).result;
expect(rows).toHaveLength(1);
expect(rows[0].span_ended_at).not.toBeNull();
});
it("accepts server-key batches with explicit user_id and no system ancestry", async ({ expect }) => {
await setupAnalyticsProject();
const { userId } = await Auth.Otp.signIn();
// Server-key request: no user access token, no refresh token.
backendContext.set({ userAuth: null });
const eventType = `sk-${randomUUID()}`;
const res = await uploadTelemetryBatch({
user_id: userId,
events: [{ event_type: eventType, event_at_ms: Date.now(), data: { source: "server" } }],
}, { accessType: "server" });
expect(res).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "inserted": 1 },
"headers": Headers { <some fields may have been hidden> },
}
`);
const queryRes = await queryAnalyticsUntil({
query: "SELECT user_id, parent_span_ids FROM events WHERE event_type = {eventType:String}",
params: { eventType },
}, (r) => r.body?.result?.length === 1);
expect(queryRes?.status).toBe(200);
const row = (queryRes?.body as any).result[0];
expect(row.user_id).toBe(userId);
// No session on server auth, so there is no system ancestry at all.
expect(row.parent_span_ids).toEqual([]);
});
it("rejects server-key batches with an unknown user_id", async ({ expect }) => {
await setupAnalyticsProject();
backendContext.set({ userAuth: null });
const res = await uploadTelemetryBatch({
user_id: randomUUID(),
events: [{ event_type: "checkout_completed", event_at_ms: Date.now(), data: {} }],
}, { accessType: "server" });
expect(res.status).toBe(400);
expect(res.body).toBe("user_id does not correspond to a user on this project/branch");
});
it("accepts server-key spans without refresh-token ancestry", async ({ expect }) => {
await setupAnalyticsProject();
const { userId } = await Auth.Otp.signIn();
backendContext.set({ userAuth: null });
const spanId = randomUUID();
const res = await uploadTelemetryBatch({
user_id: userId,
spans: [makeCustomSpan({ span_id: spanId })],
}, { accessType: "server" });
expect(res.status).toBe(200);
expect(res.body.inserted).toBe(1);
const queryRes = await queryAnalyticsUntil({
query: "SELECT id, span_type, parent_span_ids, user_id FROM spans WHERE id = {id:String}",
params: { id: `cs-${spanId}` },
}, (r) => r.body?.result?.length === 1);
expect(queryRes?.status).toBe(200);
const rows = (queryRes?.body as any).result;
expect(rows).toHaveLength(1);
expect(rows[0].span_type).toBe("checkout-flow");
expect(rows[0].user_id).toBe(userId);
// No refresh token on server auth → no rti- (or any system) ancestor.
expect(rows[0].parent_span_ids).toEqual([]);
});
it("accepts a gzipped binary body containing custom events and spans", async ({ expect }) => {
await setupAnalyticsProject();
await Auth.Otp.signIn();
const now = Date.now();
const payload = {
session_replay_segment_id: randomUUID(),
batch_id: randomUUID(),
sent_at_ms: now,
events: [{ event_type: "checkout_completed", event_at_ms: now - 100, data: { cart_size: 3 } }],
spans: [makeCustomSpan()],
};
const compressed = gzipSync(Buffer.from(JSON.stringify(payload), "utf-8"));
const res = await niceBackendFetch("/api/v1/analytics/events/batch", {
method: "POST",
accessType: "client",
rawBody: compressed,
});
expect(res).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "inserted": 2 },
"headers": Headers { <some fields may have been hidden> },
}
`);
});

View File

@ -130,7 +130,7 @@ function getBotChallengeRequestFields(botChallenge: BotChallengeInput | undefine
};
}
async function encodeGzipJsonBody(
export async function encodeGzipJsonBody(
jsonBody: string,
options: { keepalive: boolean },
): Promise<{ body: BodyInit, contentType: string }> {

View File

@ -9,6 +9,7 @@ import { Result } from "../utils/results";
import { urlString } from "../utils/urls";
import {
ClientInterfaceOptions,
encodeGzipJsonBody,
HexclaveClientInterface
} from "./client-interface";
import { ConnectedAccountAccessTokenCrud, ConnectedAccountCrud } from "./crud/connected-accounts";
@ -59,6 +60,38 @@ export class HexclaveServerInterface extends HexclaveClientInterface {
);
}
/**
* Server-auth variant of the analytics batch upload (see
* `sendAnalyticsEventBatch` on the client interface): authenticates with the
* secret server key instead of a user session, against the analytics base URL.
* Single attempt, like the client path analytics is fire-and-forget and the
* caller's returned promise carries the outcome.
*/
async sendAnalyticsEventBatchAsServer(body: string): Promise<Result<Response, Error>> {
try {
const encoded = await encodeGzipJsonBody(body, { keepalive: false });
const response = await this.sendClientRequest(
"/analytics/events/batch",
{
method: "POST",
headers: {
"Content-Type": encoded.contentType,
// Hexclave rebrand: emit x-hexclave-* request header; the backend proxy dual-accepts both names.
"x-hexclave-secret-server-key": "secretServerKey" in this.options ? this.options.secretServerKey : "",
},
body: encoded.body,
},
null,
"server",
this.getAnalyticsApiUrl(),
{ maxAttempts: 1, skipDiagnostics: true },
);
return Result.ok(response);
} catch (e) {
return Result.error(e instanceof Error ? e : new Error(String(e)));
}
}
override async getCustomerBilling(
customerType: "user" | "team",
customerId: string,

View File

@ -6,6 +6,10 @@ export { getConvexProvidersConfig } from "./integrations/convex";
export type { HexclaveConfig, StackConfig } from "@hexclave/shared/config";
export { defineHexclaveConfig, defineStackConfig } from "@hexclave/shared/config";
// Custom telemetry (trackEvent/startSpan) — platform-neutral: the methods exist
// on every SDK surface (non-browser environments no-op with inert spans).
export type { ParentRef, Span, SpanRef, StartSpanOptions, TrackOptions } from "./lib/hexclave-app/apps/implementations/event-tracker";
// IF_PLATFORM react-like
export type { AnalyticsOptions, AnalyticsReplayOptions } from "./lib/hexclave-app/apps/implementations/session-replay";
// Hexclave aliases and legacy Stack* names — @deprecated JSDoc lives on the original

View File

@ -61,7 +61,7 @@ import { ActiveSession, Auth, BaseUser, CurrentUser, InternalUserExtra, OAuthPro
import { StackClientApp, StackClientAppConstructorOptions, StackClientAppJson } from "../interfaces/client-app";
import { _HexclaveAdminAppImplIncomplete } from "./admin-app-impl";
import { TokenObject, clientVersion, createCache, createCacheBySession, createEmptyTokenStore, getAnalyticsBaseUrl, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getUrls, resolveApiUrls, resolveConstructorOptions } from "./common";
import { EventTracker } from "./event-tracker";
import { createInertSpan, EventTracker, getCustomTelemetryDataError, getCustomTelemetryNameError, rejectedPreCaught, warnTelemetryUnavailableOnce, type Span, type StartSpanOptions, type TrackOptions } from "./event-tracker";
import type { CrossDomainHandoffParams } from "./redirect-page-urls";
import { crossDomainAuthQueryParams, getCrossDomainHandoffParamsFromCurrentUrl, planRedirectToHandler } from "./redirect-page-urls";
import { subscribeSessionRefresh } from "./session-refresh-subscription";
@ -258,7 +258,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
private readonly _analyticsOptions: AnalyticsOptions | undefined;
private _sessionRecorder: SessionRecorder | null = null;
private _eventTracker: EventTracker | null = null;
protected _eventTracker: EventTracker | null = null;
private __DEMO_ENABLE_SLIGHT_FETCH_DELAY = false;
private readonly _ownedAdminApps = new DependenciesMap<[InternalSession, string], _HexclaveAdminAppImplIncomplete<false, string>>();
@ -3995,6 +3995,48 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
}
}
// Custom telemetry (see the StackClientApp interface for user-facing docs).
// The tracker only exists in browser-like environments with analytics enabled
// and a persistent token store; everywhere else these are validated no-ops so
// isomorphic code never needs to branch (and misuse still surfaces loudly).
trackEvent(eventType: string, data?: Record<string, unknown>, options?: TrackOptions): Promise<void> {
if (this._eventTracker) {
return this._eventTracker.trackCustomEvent(eventType, data, options);
}
const nameError = getCustomTelemetryNameError("event", eventType);
if (nameError) return rejectedPreCaught(nameError);
const dataError = getCustomTelemetryDataError(data);
if (dataError) return rejectedPreCaught(dataError);
warnTelemetryUnavailableOnce();
return Promise.resolve();
}
startSpan(spanType: string, options?: StartSpanOptions): Span {
if (this._eventTracker) {
return this._eventTracker.startSpan(spanType, options);
}
const nameError = getCustomTelemetryNameError("span", spanType);
if (nameError) {
console.error(`Hexclave analytics: ${nameError}`);
} else {
warnTelemetryUnavailableOnce();
}
return createInertSpan(spanType);
}
setGlobalSpan(span: Span): void {
this._eventTracker?.setGlobalSpan(span);
}
unsetGlobalSpan(span: Span): void {
this._eventTracker?.unsetGlobalSpan(span);
}
async flush(): Promise<void> {
await this._eventTracker?.flush();
}
async getAccessToken(options?: { tokenStore?: TokenStoreInit }): Promise<string | null> {
const user = await this.getUser({ tokenStore: options?.tokenStore ?? undefined as any });
if (user) {

View File

@ -420,6 +420,320 @@ describe("EventTracker", () => {
}
});
it("buffers custom events alongside system events and resolves their promises on ack", async () => {
vi.useFakeTimers();
document.body.innerHTML = "";
const sentBodies: string[] = [];
const tracker = new EventTracker({
projectId: "internal",
sendBatch: async (body) => {
sentBodies.push(body);
return Result.ok(new Response());
},
});
try {
tracker.start();
const promise = tracker.trackCustomEvent("checkout_completed", { cart_size: 3 });
await advancePastFlush();
await expect(promise).resolves.toBeUndefined();
const payload = JSON.parse(sentBodies[0] ?? "{}") as { events: { event_type: string, data: Record<string, unknown>, parent_span_ids?: string[] }[] };
expect(payload.events.map((event) => event.event_type)).toEqual(["$page-view", "checkout_completed"]);
const custom = payload.events[1];
expect(custom.data).toEqual({ cart_size: 3 });
// No parents were given and no globals are set — the key is omitted
// entirely (the server stamps system ancestry on every event anyway).
expect(custom.parent_span_ids).toBeUndefined();
} finally {
tracker.stop();
}
});
it("rejects (pre-caught, never throws) on invalid names, data, and parent ids", async () => {
vi.useFakeTimers();
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const tracker = new EventTracker({
projectId: "internal",
sendBatch: async () => Result.ok(new Response()),
});
try {
tracker.start();
// $-prefixed names are reserved for system telemetry.
await expect(tracker.trackCustomEvent("$page-view")).rejects.toThrow(/reserved for system telemetry/);
await expect(tracker.trackCustomEvent("1-starts-with-digit")).rejects.toThrow(/must start with a letter/);
await expect(tracker.trackCustomEvent("x".repeat(65))).rejects.toThrow(/at most 64 characters/);
await expect(tracker.trackCustomEvent("ok", [1, 2] as any)).rejects.toThrow(/plain JSON-serializable object/);
await expect(tracker.trackCustomEvent("ok", { big: "x".repeat(17_000) })).rejects.toThrow(/at most 16000 bytes/);
await expect(tracker.trackCustomEvent("ok", {}, { parentIds: ["not-a-uuid"] })).rejects.toThrow(/parent ids must be span uuids/);
// Ignoring the rejected promise entirely must not blow up the test run
// (the internal catch keeps it from ever being an unhandled rejection).
tracker.trackCustomEvent("$ignored").catch(() => {});
expect(errorSpy).toHaveBeenCalled();
// Invalid startSpan input yields an inert span rather than a throw.
const inert = tracker.startSpan("$reserved");
await expect(inert.end()).resolves.toBeUndefined();
// Nothing invalid was buffered.
await advancePastFlush();
} finally {
tracker.stop();
errorSpy.mockRestore();
}
});
it("writes a span open on start, dedupes in-batch re-writes to the latest row, and settles every promise", async () => {
vi.useFakeTimers();
const sentBodies: string[] = [];
const tracker = new EventTracker({
projectId: "internal",
sendBatch: async (body) => {
sentBodies.push(body);
return Result.ok(new Response());
},
});
try {
tracker.start();
const span = tracker.startSpan("checkout-flow", { data: { cart_size: 3 } });
const setDataPromise = span.setData({ coupon: "SAVE10" });
const endPromise = span.end();
expect(span.isEnded).toBe(true);
// end() is idempotent: repeat calls return the first call's promise.
expect(span.end()).toBe(endPromise);
await advancePastFlush();
await expect(setDataPromise).resolves.toBeUndefined();
await expect(endPromise).resolves.toBeUndefined();
// Start + setData + end within one flush window: exactly ONE wire row,
// carrying the latest state (ended, merged data).
const payload = JSON.parse(sentBodies[0] ?? "{}") as { spans?: { span_id: string, span_type: string, ended_at_ms: number | null, data: Record<string, unknown>, parent_span_ids: string[] }[] };
expect(payload.spans).toHaveLength(1);
const row = payload.spans![0];
expect(row.span_id).toBe(span.spanId);
expect(row.span_type).toBe("checkout-flow");
expect(row.ended_at_ms).not.toBeNull();
expect(row.data).toEqual({ cart_size: 3, coupon: "SAVE10" });
expect(row.parent_span_ids).toEqual([]);
} finally {
tracker.stop();
}
});
it("re-writes a span across flushes with a strictly increasing version", async () => {
vi.useFakeTimers();
const sentBodies: string[] = [];
const tracker = new EventTracker({
projectId: "internal",
sendBatch: async (body) => {
sentBodies.push(body);
return Result.ok(new Response());
},
});
try {
tracker.start();
const span = tracker.startSpan("upload");
await advancePastFlush();
span.end().catch(() => {});
await advancePastFlush();
const first = JSON.parse(sentBodies[0] ?? "{}") as { spans?: { ended_at_ms: number | null, updated_at_ms: number }[] };
const second = JSON.parse(sentBodies[1] ?? "{}") as { spans?: { ended_at_ms: number | null, updated_at_ms: number }[] };
expect(first.spans![0].ended_at_ms).toBeNull();
expect(second.spans![0].ended_at_ms).not.toBeNull();
// The end row must always beat the open row in the ReplacingMergeTree,
// even if the batches arrive out of order server-side.
expect(second.spans![0].updated_at_ms).toBeGreaterThan(first.spans![0].updated_at_ms);
} finally {
tracker.stop();
}
});
it("parents children and events under global spans and handle chains (span2.trackEvent inherits everything)", async () => {
vi.useFakeTimers();
const sentBodies: string[] = [];
const tracker = new EventTracker({
projectId: "internal",
sendBatch: async (body) => {
sentBodies.push(body);
return Result.ok(new Response());
},
});
try {
tracker.start();
const checkout = tracker.startSpan("checkout-flow");
tracker.setGlobalSpan(checkout);
// Child created via the handle: chain = [checkout].
const payment = checkout.startSpan("payment-step");
// Event tracked via the child handle: parents = ambient (checkout) +
// payment's chain (checkout, deduped) + payment itself.
const eventPromise = payment.trackEvent("card_declined", { code: "51" });
// A raw uuid parent contributes only itself, additively.
const rawParent = "0f000000-0000-4000-8000-00000000cccc";
tracker.trackCustomEvent("hint_shown", {}, { parentIds: [rawParent] }).catch(() => {});
await advancePastFlush();
await expect(eventPromise).resolves.toBeUndefined();
const payload = JSON.parse(sentBodies[0] ?? "{}") as {
events: { event_type: string, parent_span_ids?: string[] }[],
spans?: { span_id: string, parent_span_ids: string[] }[],
};
const paymentRow = payload.spans!.find((row) => row.span_id === payment.spanId);
expect(paymentRow!.parent_span_ids).toEqual([checkout.spanId]);
const declined = payload.events.find((event) => event.event_type === "card_declined");
expect(declined!.parent_span_ids).toEqual([checkout.spanId, payment.spanId]);
const hint = payload.events.find((event) => event.event_type === "hint_shown");
expect(hint!.parent_span_ids).toEqual([checkout.spanId, rawParent]);
// Ending a global span auto-unsets it: subsequent events have no parents.
checkout.end().catch(() => {});
tracker.trackCustomEvent("after_end").catch(() => {});
await advancePastFlush();
const second = JSON.parse(sentBodies[1] ?? "{}") as { events: { event_type: string, parent_span_ids?: string[] }[] };
const after = second.events.find((event) => event.event_type === "after_end");
expect(after!.parent_span_ids).toBeUndefined();
} finally {
tracker.stop();
}
});
it("continues a span tree from a serialized SpanRef with full ancestry", async () => {
vi.useFakeTimers();
const sentBodies: string[] = [];
const tracker = new EventTracker({
projectId: "internal",
sendBatch: async (body) => {
sentBodies.push(body);
return Result.ok(new Response());
},
});
try {
tracker.start();
// Simulates a span minted on another tier (e.g. the server) and passed
// through JSON: the ref carries its own ancestor chain.
const serverRef = {
spanId: "0f000000-0000-4000-8000-00000000dddd",
parentSpanIds: ["0f000000-0000-4000-8000-00000000eeee"],
};
const child = tracker.startSpan("client-continuation", { parentIds: [serverRef] });
await advancePastFlush();
const payload = JSON.parse(sentBodies[0] ?? "{}") as { spans?: { span_id: string, parent_span_ids: string[] }[] };
expect(payload.spans![0].span_id).toBe(child.spanId);
expect(payload.spans![0].parent_span_ids).toEqual([serverRef.parentSpanIds[0], serverRef.spanId]);
expect(child.ref()).toEqual({ spanId: child.spanId, parentSpanIds: [serverRef.parentSpanIds[0], serverRef.spanId] });
} finally {
tracker.stop();
}
});
it("rejects pending promises on failed sends without unhandled rejections, and flush() sends immediately", async () => {
vi.useFakeTimers();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
let failNext = true;
const sentBodies: string[] = [];
const tracker = new EventTracker({
projectId: "internal",
sendBatch: async (body) => {
sentBodies.push(body);
return failNext ? Result.error(new TypeError("Failed to fetch")) : Result.ok(new Response());
},
});
try {
tracker.start();
const failed = tracker.trackCustomEvent("will_fail");
// flush() is the "send now" escape hatch — no timer advance needed.
await tracker.flush();
expect(sentBodies).toHaveLength(1);
await expect(failed).rejects.toThrow("Failed to fetch");
failNext = false;
const succeeded = tracker.trackCustomEvent("will_succeed");
await tracker.flush();
await expect(succeeded).resolves.toBeUndefined();
} finally {
tracker.stop();
warnSpy.mockRestore();
}
});
it("clearBuffer drops pending telemetry and inert-ifies live spans (sign-out privacy)", async () => {
vi.useFakeTimers();
const sentBodies: string[] = [];
const tracker = new EventTracker({
projectId: "internal",
sendBatch: async (body) => {
sentBodies.push(body);
return Result.ok(new Response());
},
});
try {
tracker.start();
const span = tracker.startSpan("cross-user-flow");
const pendingEvent = tracker.trackCustomEvent("pre_signout");
tracker.clearBuffer();
await expect(pendingEvent).rejects.toThrow(/buffer cleared/);
// A post-clear end() must not write anything: the handle is inert, so a
// span started by user A can never be re-written under user B.
await expect(span.end()).resolves.toBeUndefined();
await advancePastFlush();
for (const body of sentBodies) {
const payload = JSON.parse(body) as { events: { event_type: string }[], spans?: unknown[] };
expect(payload.spans).toBeUndefined();
expect(payload.events.every((event) => event.event_type === "$page-view")).toBe(true);
}
} finally {
tracker.stop();
}
});
it("ships buffered spans on the keepalive pagehide flush (open spans stay open)", async () => {
vi.useFakeTimers();
const sentBodies: string[] = [];
const tracker = new EventTracker({
projectId: "internal",
sendBatch: async (body) => {
sentBodies.push(body);
return Result.ok(new Response());
},
});
try {
tracker.start();
tracker.startSpan("abandoned-flow");
window.dispatchEvent(new Event("pagehide"));
await Promise.resolve();
await Promise.resolve();
const payload = JSON.parse(sentBodies[0] ?? "{}") as { spans?: { span_type: string, ended_at_ms: number | null }[] };
expect(payload.spans).toHaveLength(1);
expect(payload.spans![0].span_type).toBe("abandoned-flow");
// No auto-end on unload: the span survives as an open interval by design.
expect(payload.spans![0].ended_at_ms).toBeNull();
} finally {
tracker.stop();
}
});
it("silently disables when client interface returns ANALYTICS_NOT_ENABLED as an error", async () => {
vi.useFakeTimers();
document.body.innerHTML = "<button>Click me</button>";

View File

@ -10,6 +10,206 @@ const FLUSH_INTERVAL_MS = 10_000;
const MAX_EVENTS_PER_BATCH = 50;
const MAX_APPROX_BYTES_PER_BATCH = 64_000;
// ---------------------------------------------------------------------------
// Custom telemetry (public trackEvent/startSpan API)
// ---------------------------------------------------------------------------
// Custom (user-defined) event/span type names: must not start with `$` (reserved
// for system types), start with a letter, and stay within 64 chars. Keep in sync
// with the server-side validation in apps/backend's analytics events batch route.
const CUSTOM_TELEMETRY_NAME_RE = /^[a-zA-Z][a-zA-Z0-9_.:-]{0,63}$/;
// Serialized size cap per event/span data payload; mirrors the server cap so a
// locally-accepted item can never poison a whole batch with a server-side 400.
const MAX_ITEM_DATA_BYTES = 16_000;
// Maximum length of the merged custom parent chain per item; mirrors the server.
const MAX_PARENT_CHAIN = 10;
// Mirrors the server's UUID_RE — raw parent ids that fail this locally would
// 400 the entire batch server-side, so they must never enter the buffer.
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
/**
* Serializable form of a span's identity + full custom ancestor chain. Survives
* JSON boundaries (page props, headers), so a span started on one tier can be
* continued as a parent on another with full ancestry unlike a bare uuid
* string, which contributes only itself.
*/
export type SpanRef = {
spanId: string,
parentSpanIds: string[],
};
/**
* Anything accepted as a parent: a raw span uuid (contributes only itself its
* ancestors are unknowable), a serialized SpanRef, or a live Span handle (both
* contribute their full ancestor chain plus themselves).
*/
export type ParentRef = string | SpanRef | Span;
export type TrackOptions = {
parentIds?: ParentRef[],
};
export type StartSpanOptions = {
data?: Record<string, unknown>,
parentIds?: ParentRef[],
startedAtMs?: number,
};
/**
* A custom span: a time interval written to analytics as an open interval on
* start and re-written (versioned upsert) on setData/end. A span that is never
* ended e.g. the tab closed stays visible as an open interval by design.
*
* Returned promises resolve when the batch containing the update is acknowledged
* and reject on definitive send failure; they are pre-caught internally, so
* ignoring them never causes unhandled-rejection noise. No method throws.
*/
export type Span = {
readonly spanId: string,
readonly spanType: string,
readonly isEnded: boolean,
/** Shallow-merges into the span's data and re-writes the span. */
setData(data: Record<string, unknown>): Promise<void>,
/** Idempotent; repeated calls return the first call's promise. */
end(options?: { endedAtMs?: number }): Promise<void>,
/** Tracks an event with this span (and its full ancestor chain) as a parent. */
trackEvent(eventType: string, data?: Record<string, unknown>, options?: TrackOptions): Promise<void>,
/** Starts a child span of this span. */
startSpan(spanType: string, options?: StartSpanOptions): Span,
/** Serializable identity + full custom ancestor chain (see SpanRef). */
ref(): SpanRef,
};
export type SpanUpdateRow = {
span_id: string,
span_type: string,
started_at_ms: number,
ended_at_ms: number | null,
parent_span_ids: string[],
data: Record<string, unknown>,
updated_at_ms: number,
};
type Settler = {
resolve: () => void,
reject: (error: unknown) => void,
};
// Subscribing an internal no-op handler means an ignored rejection never counts
// as "unhandled" (the runtime only reports rejected promises with zero
// subscribers), while callers who do await still observe it through their own
// handler. This is what makes fire-and-forget usage safe.
function preCaught<T>(promise: Promise<T>): Promise<T> {
promise.catch(() => {});
return promise;
}
export function rejectedPreCaught(message: string): Promise<never> {
console.error(`Hexclave analytics: ${message}`);
return preCaught(Promise.reject(new Error(message)));
}
let warnedTelemetryUnavailable = false;
export function warnTelemetryUnavailableOnce(): void {
if (warnedTelemetryUnavailable) return;
warnedTelemetryUnavailable = true;
console.warn("Hexclave analytics: trackEvent/startSpan called where analytics is unavailable (non-browser environment, no persistent token store, or analytics disabled); telemetry is dropped");
}
export function getCustomTelemetryNameError(kind: "event" | "span", name: unknown): string | null {
if (typeof name !== "string" || !CUSTOM_TELEMETRY_NAME_RE.test(name)) {
return `Invalid custom ${kind} type ${JSON.stringify(name)}: must start with a letter, contain only letters, digits, "_", ".", ":" or "-", and be at most 64 characters ("$"-prefixed names are reserved for system telemetry)`;
}
return null;
}
export function getCustomTelemetryDataError(data: unknown): string | null {
if (data === undefined) return null;
if (data === null || typeof data !== "object" || Array.isArray(data)) {
return "Telemetry data must be a plain JSON-serializable object";
}
// JSON.stringify's lib type is `string` but it returns undefined at runtime
// for some inputs — keep the check honest with an explicit widened type.
let serialized: string | undefined;
try {
serialized = JSON.stringify(data) as string | undefined;
} catch {
return "Telemetry data must be JSON-serializable (no circular references or BigInt values)";
}
if (serialized === undefined || serialized.length > MAX_ITEM_DATA_BYTES) {
return `Telemetry data must serialize to at most ${MAX_ITEM_DATA_BYTES} bytes`;
}
return null;
}
/**
* Merges ambient parents (e.g. global spans) and explicit ParentRefs into one
* root-first, deduped id chain. Each Span/SpanRef contributes its full frozen
* ancestor chain plus itself; a raw string uuid contributes only itself.
* Root-first order is preserved because every contributor is itself root-first
* and first-occurrence dedupe keeps the earliest (root-most) position.
*/
export function resolveParentIds(opts: {
explicit?: ParentRef[],
ambient?: SpanRef[],
}): { ids: string[] } | { error: string } {
const chains: string[][] = [];
for (const ambient of opts.ambient ?? []) {
chains.push([...ambient.parentSpanIds, ambient.spanId]);
}
for (const parent of opts.explicit ?? []) {
if (typeof parent === "string") {
chains.push([parent]);
} else {
const ref = "ref" in parent && typeof parent.ref === "function" ? parent.ref() : parent as SpanRef;
chains.push([...ref.parentSpanIds, ref.spanId]);
}
}
const seen = new Set<string>();
const merged: string[] = [];
for (const chain of chains) {
for (const id of chain) {
if (!UUID_RE.test(id)) {
return { error: `Invalid parent span id ${JSON.stringify(id)}: parent ids must be span uuids` };
}
if (!seen.has(id)) {
seen.add(id);
merged.push(id);
}
}
}
if (merged.length > MAX_PARENT_CHAIN) {
console.warn(`Hexclave analytics: parent chain exceeds ${MAX_PARENT_CHAIN} spans; keeping the ${MAX_PARENT_CHAIN} nearest ancestors`);
return { ids: merged.slice(-MAX_PARENT_CHAIN) };
}
return { ids: merged };
}
/**
* A Span that records nothing. Returned wherever analytics cannot run (SSR,
* analytics disabled, tracker torn down) so isomorphic user code never needs to
* branch: every method succeeds immediately.
*/
export function createInertSpan(spanType: string): Span {
let ended = false;
const span: Span = {
spanId: generateUuid(),
spanType,
get isEnded() {
return ended;
},
setData: () => Promise.resolve(),
end: () => {
ended = true;
return Promise.resolve();
},
trackEvent: () => Promise.resolve(),
startSpan: (childType: string) => createInertSpan(childType),
ref: () => ({ spanId: span.spanId, parentSpanIds: [] }),
};
return span;
}
function hasScreenDimensions(value: unknown): value is { width: number, height: number } {
if (value == null || typeof value !== "object") {
return false;
@ -108,9 +308,14 @@ export type EventTrackerDeps = {
};
type TrackedEvent = {
event_type: "$page-view" | "$click",
// System types ($page-view, $click) from the auto-capture paths, or a custom
// name (validated against CUSTOM_TELEMETRY_NAME_RE) from trackEvent().
event_type: string,
event_at_ms: number,
data: Record<string, unknown>,
// Custom ancestor chain, root-first, raw span uuids. Omitted for system
// events; the server composes system ancestry on top for every event.
parent_span_ids?: string[],
};
export class EventTracker {
@ -128,6 +333,22 @@ export class EventTracker {
private _originalPushState: History["pushState"] | null = null;
private _originalReplaceState: History["replaceState"] | null = null;
// Custom-span updates awaiting the next flush, latest row per span id (a span
// touched N times within one flush window costs one wire row). Settlers from
// superseded rows are carried over so every returned promise still settles
// with the batch that actually carries the span's latest state.
private _spanUpdates = new Map<string, { row: SpanUpdateRow, settlers: Settler[] }>();
// Settlers for buffered custom events (system events are fire-and-forget).
private _eventSettlers = new Map<TrackedEvent, Settler>();
// Spans registered via setGlobalSpan — ambient parents for all subsequent
// custom events and spans until unset (end() auto-unsets).
private _globalSpans = new Set<Span>();
// Live (un-ended) span handles' inert switches; flipped on clearBuffer so a
// span started before sign-out can never be re-written under the next user.
private _liveSpanControls = new Set<{ markInert: () => void }>();
// Batch sends currently on the wire; flush() awaits these.
private _inFlight = new Set<Promise<void>>();
private _deadClickTimer: ReturnType<typeof setInterval> | null = null;
private _deadClickMutationObserver: MutationObserver | null = null;
// Buffered $click events still awaiting dead-click classification. Always a
@ -176,11 +397,35 @@ export class EventTracker {
}
clearBuffer() {
this._settleAllPending("analytics buffer cleared");
this._events = [];
this._approxBytes = 0;
this._unclassifiedClicks.clear();
}
// Rejects every pending custom-event/span promise (pre-caught, so silent for
// fire-and-forget callers), drops buffered span rows, and inert-ifies all live
// span handles. Called on sign-out (paired with the segment-id rotation): a
// span started under user A must never be re-written under user B's session.
private _settleAllPending(reason: string) {
const error = new Error(`Hexclave analytics: ${reason}`);
for (const settler of this._eventSettlers.values()) {
settler.reject(error);
}
this._eventSettlers.clear();
for (const entry of this._spanUpdates.values()) {
for (const settler of entry.settlers) {
settler.reject(error);
}
}
this._spanUpdates.clear();
for (const control of this._liveSpanControls) {
control.markInert();
}
this._liveSpanControls.clear();
this._globalSpans.clear();
}
/**
* Replaces the per-tab id shared with the SessionRecorder. Called on sign-out
* (paired with clearBuffer) so a subsequent same-tab sign-in as a different user
@ -192,13 +437,200 @@ export class EventTracker {
this._sessionReplaySegmentId = id;
}
/**
* Buffers a custom analytics event. The returned promise resolves when the
* batch carrying the event is acknowledged and rejects on definitive send
* failure; it is pre-caught, so ignoring it is safe. Never throws invalid
* input yields a rejected promise plus a console error.
*/
trackCustomEvent(eventType: string, data?: Record<string, unknown>, options?: TrackOptions): Promise<void> {
const nameError = getCustomTelemetryNameError("event", eventType);
if (nameError) return rejectedPreCaught(nameError);
const dataError = getCustomTelemetryDataError(data);
if (dataError) return rejectedPreCaught(dataError);
const resolved = resolveParentIds({ explicit: options?.parentIds, ambient: this._ambientParentRefs() });
if ("error" in resolved) return rejectedPreCaught(resolved.error);
if (this._disabled) return Promise.resolve();
const event: TrackedEvent = {
event_type: eventType,
event_at_ms: Date.now(),
data: { ...data ?? {} },
...resolved.ids.length > 0 ? { parent_span_ids: resolved.ids } : {},
};
let settler!: Settler;
const promise = preCaught(new Promise<void>((resolve, reject) => {
settler = { resolve, reject };
}));
this._eventSettlers.set(event, settler);
this._pushEvent(event);
return promise;
}
/**
* Starts a custom span: the open interval is written on the next flush and
* re-written (versioned upsert) on setData/end. Never throws invalid input
* yields an inert span plus a console error, so caller code always proceeds.
*/
startSpan(spanType: string, options?: StartSpanOptions): Span {
const nameError = getCustomTelemetryNameError("span", spanType);
if (nameError) {
console.error(`Hexclave analytics: ${nameError}`);
return createInertSpan(spanType);
}
const dataError = getCustomTelemetryDataError(options?.data);
if (dataError) {
console.error(`Hexclave analytics: ${dataError}`);
return createInertSpan(spanType);
}
if (options?.startedAtMs !== undefined && (!Number.isInteger(options.startedAtMs) || options.startedAtMs < 0)) {
console.error(`Hexclave analytics: startedAtMs must be a non-negative integer epoch-milliseconds value`);
return createInertSpan(spanType);
}
const resolved = resolveParentIds({ explicit: options?.parentIds, ambient: this._ambientParentRefs() });
if ("error" in resolved) {
console.error(`Hexclave analytics: ${resolved.error}`);
return createInertSpan(spanType);
}
if (this._disabled) return createInertSpan(spanType);
const spanId = generateUuid();
// The custom ancestor chain is frozen at creation: parents are identity, not
// state, so later setGlobalSpan calls or parent mutations never re-parent an
// existing span (and every re-write of this span carries the same chain).
const parentSpanIds = resolved.ids;
const startedAtMs = options?.startedAtMs ?? Date.now();
let accumulatedData: Record<string, unknown> = { ...options?.data ?? {} };
let lastVersion = 0;
let ended = false;
let inert = false;
let endPromise: Promise<void> | null = null;
// Per-span monotonic version: the row carrying the latest update always wins
// in the ReplacingMergeTree, even when batches arrive out of order (keepalive
// sends are single-attempt and can race a normal flush).
const nextVersion = () => (lastVersion = Math.max(Date.now(), lastVersion + 1));
const enqueue = (endedAtMs: number | null): Promise<void> => {
if (inert || this._disabled) return Promise.resolve();
return this._enqueueSpanUpdate({
span_id: spanId,
span_type: spanType,
started_at_ms: startedAtMs,
ended_at_ms: endedAtMs,
parent_span_ids: parentSpanIds,
data: { ...accumulatedData },
updated_at_ms: nextVersion(),
});
};
const control = {
markInert: () => {
inert = true;
},
};
this._liveSpanControls.add(control);
const span: Span = {
spanId,
spanType,
get isEnded() {
return ended;
},
setData: (data: Record<string, unknown>) => {
if (ended) return rejectedPreCaught(`setData() called on already-ended span "${spanType}"`);
const merged = { ...accumulatedData, ...data };
const mergedError = getCustomTelemetryDataError(merged);
if (mergedError) return rejectedPreCaught(mergedError);
accumulatedData = merged;
return enqueue(null);
},
end: (endOptions?: { endedAtMs?: number }) => {
if (endPromise) return endPromise;
ended = true;
this._globalSpans.delete(span);
this._liveSpanControls.delete(control);
// Clamp so a caller-supplied end can never invert the interval — the
// server rejects ended < started, and one bad item would 400 the batch.
const endedAtMs = Math.max(startedAtMs, Math.round(endOptions?.endedAtMs ?? Date.now()));
endPromise = enqueue(endedAtMs);
return endPromise;
},
trackEvent: (eventType: string, data?: Record<string, unknown>, trackOptions?: TrackOptions) =>
this.trackCustomEvent(eventType, data, { ...trackOptions, parentIds: [span, ...trackOptions?.parentIds ?? []] }),
startSpan: (childType: string, childOptions?: StartSpanOptions) =>
this.startSpan(childType, { ...childOptions, parentIds: [span, ...childOptions?.parentIds ?? []] }),
ref: () => ({ spanId, parentSpanIds: [...parentSpanIds] }),
};
// Write the open interval right away: a span the user never ends (e.g. the
// tab closes) still shows up, as an open interval, from its first flush on.
enqueue(null).catch(() => {});
return span;
}
/**
* Registers a span as an ambient parent for all subsequently created custom
* events and spans (additive with explicit parentIds). Ending the span
* automatically unregisters it.
*/
setGlobalSpan(span: Span): void {
if (span.isEnded) {
console.warn("Hexclave analytics: setGlobalSpan() called with an already-ended span; ignoring");
return;
}
this._globalSpans.add(span);
}
unsetGlobalSpan(span: Span): void {
this._globalSpans.delete(span);
}
/**
* Sends everything buffered right now and settles all in-flight sends. This is
* the "send now" escape hatch awaiting trackEvent alone waits for the
* regular flush cadence.
*/
async flush(): Promise<void> {
await this._flush({ keepalive: false });
await Promise.allSettled([...this._inFlight]);
}
private _ambientParentRefs(): SpanRef[] {
const refs: SpanRef[] = [];
for (const span of this._globalSpans) {
if (!span.isEnded) refs.push(span.ref());
}
return refs;
}
private _enqueueSpanUpdate(row: SpanUpdateRow): Promise<void> {
let settler!: Settler;
const promise = preCaught(new Promise<void>((resolve, reject) => {
settler = { resolve, reject };
}));
const previous = this._spanUpdates.get(row.span_id);
if (previous) {
this._approxBytes -= JSON.stringify(previous.row).length;
}
// Latest row per span id wins within a batch, but superseded rows' settlers
// ride along so their promises still settle with the batch that ships.
this._spanUpdates.set(row.span_id, { row, settlers: [...previous?.settlers ?? [], settler] });
this._approxBytes += JSON.stringify(row).length;
this._maybeTriggerSizeFlush();
return promise;
}
private _maybeTriggerSizeFlush() {
if (this._events.length + this._spanUpdates.size >= MAX_EVENTS_PER_BATCH || this._approxBytes >= MAX_APPROX_BYTES_PER_BATCH) {
runAsynchronously(() => this._flush({ keepalive: false }));
}
}
private _pushEvent(event: TrackedEvent) {
if (this._disabled) return;
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) {
runAsynchronously(() => this._flush({ keepalive: false }));
}
this._maybeTriggerSizeFlush();
}
private _capturePageView(entryType: "initial" | "push" | "replace" | "pop") {
@ -480,6 +912,7 @@ export class EventTracker {
document.removeEventListener("click", this._onClickCapture, { capture: true });
this._teardownDeadClickDetection();
this._settleAllPending("analytics tracker stopped");
this._events = [];
this._approxBytes = 0;
}
@ -496,12 +929,29 @@ export class EventTracker {
// Clicks still awaiting classification stay buffered so the sweep can
// mark them dead in place; classification finishes well within one flush
// interval, so they ride the next flush at the latest.
// interval, so they ride the next flush at the latest. Span rows are never
// held back — the holdback exists only for dead-click classification.
const events = this._events.filter((event) => !this._unclassifiedClicks.has(event));
if (events.length === 0) return;
const spanEntries = [...this._spanUpdates.values()];
if (events.length === 0 && spanEntries.length === 0) return;
this._events = this._events.filter((event) => this._unclassifiedClicks.has(event));
this._spanUpdates.clear();
this._approxBytes = this._events.reduce((total, event) => total + JSON.stringify(event).length, 0);
// Snapshot the settlers of everything this batch carries: they settle with
// this send's outcome. Items buffered after this point ride the next flush.
const settlers: Settler[] = [];
for (const event of events) {
const settler = this._eventSettlers.get(event);
if (settler) {
settlers.push(settler);
this._eventSettlers.delete(event);
}
}
for (const entry of spanEntries) {
settlers.push(...entry.settlers);
}
const nowMs = Date.now();
const batchId = generateUuid();
@ -510,30 +960,54 @@ export class EventTracker {
batch_id: batchId,
sent_at_ms: nowMs,
events,
...spanEntries.length > 0 ? { spans: spanEntries.map((entry) => entry.row) } : {},
};
const res = await this._deps.sendBatch(
JSON.stringify(payload),
{ keepalive: options.keepalive },
);
const send = (async () => {
try {
const res = await this._deps.sendBatch(
JSON.stringify(payload),
{ keepalive: options.keepalive },
);
if (res.status === "error") {
if (isAnalyticsNotEnabledError(res.error)) {
this._disable();
return;
}
// Ad blockers commonly block analytics endpoints, causing network
// errors. These are expected and should not pollute the console.
if (isAdBlockerNetworkError(res.error)) {
return;
}
console.warn("EventTracker flush failed:", res.error);
return;
}
if (res.status === "error") {
// All rejections are pre-caught at promise creation, so failures are
// silent for fire-and-forget callers and observable for awaiting ones.
for (const settler of settlers) settler.reject(res.error);
if (isAnalyticsNotEnabledError(res.error)) {
this._disable();
return;
}
// Ad blockers commonly block analytics endpoints, causing network
// errors. These are expected and should not pollute the console.
if (isAdBlockerNetworkError(res.error)) {
return;
}
console.warn("EventTracker flush failed:", res.error);
return;
}
if (!res.data.ok) {
console.warn("EventTracker flush failed:", res.data.status, await res.data.text());
}
if (!res.data.ok) {
const text = await res.data.text();
for (const settler of settlers) settler.reject(new Error(`EventTracker flush failed: ${res.data.status} ${text}`));
console.warn("EventTracker flush failed:", res.data.status, text);
return;
}
for (const settler of settlers) settler.resolve();
} catch (error) {
// _flush must never reject (public flush() and fire-and-forget callers
// don't expect telemetry failures to throw); the settlers carry it.
for (const settler of settlers) settler.reject(error);
console.warn("EventTracker flush failed:", error);
}
})();
const tracked: Promise<void> = send.finally(() => {
this._inFlight.delete(tracked);
});
this._inFlight.add(tracked);
await tracked;
}
private _disable() {
@ -547,7 +1021,7 @@ export class EventTracker {
private _tick() {
if (this._cancelled) return;
if (this._events.length > 0) {
if (this._events.length > 0 || this._spanUpdates.size > 0) {
runAsynchronously(() => this._flush({ keepalive: false }));
}
}

View File

@ -36,6 +36,8 @@ import { ProjectCurrentServerUser, ServerOAuthProvider, ServerUser, ServerUserCr
import { StackServerAppConstructorOptions } from "../interfaces/server-app";
import { _HexclaveClientAppImplIncomplete } from "./client-app-impl";
import { clientVersion, createCache, createCacheBySession, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, resolveApiUrls, resolveConstructorOptions } from "./common";
import { createInertSpan, getCustomTelemetryDataError, getCustomTelemetryNameError, rejectedPreCaught, resolveParentIds, type Span, type SpanRef, type SpanUpdateRow, type StartSpanOptions, type TrackOptions } from "./event-tracker";
import { generateUuid } from "./session-replay";
import { useAsyncCache } from "./common"; // THIS_LINE_PLATFORM react-like
@ -1698,4 +1700,294 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
throw error;
}
}
// ---------------------------------------------------------------------------
// Custom telemetry (server-key sends)
//
// The browser tracker (EventTracker) only exists in browser-like environments;
// on the server there is no session to derive identity from, so trackEvent/
// startSpan take an explicit `userId` and authenticate with the secret server
// key. Items coalesce per (userId) in a buffer flushed on the next microtask —
// a synchronous loop of N trackEvent calls costs one POST, not N — while every
// call still gets its own settled-on-ack promise. `await` (or flush()) is the
// delivery guarantee: there is no page-lifetime flush cadence on the server.
//
// NOTE: setGlobalSpan is app-instance-level state. Under concurrent requests
// (one shared app instance) a global span set in one request becomes a parent
// in all of them — prefer explicit parentIds (or span.trackEvent) on servers.
// ---------------------------------------------------------------------------
private readonly _serverTelemetryBuffers = new Map<string | null, ServerTelemetryBuffer>();
private readonly _serverGlobalSpans = new Set<Span>();
private readonly _serverTelemetryInFlight = new Set<Promise<void>>();
override trackEvent(eventType: string, data?: Record<string, unknown>, options?: TrackOptions & { userId?: string }): Promise<void> {
if (this._eventTracker) {
// Browser-like environment: identity comes from the session; an explicit
// userId would silently mis-attribute, so refuse it loudly.
if (options?.userId !== undefined) {
return rejectedPreCaught("userId is only supported for server-key telemetry; in the browser, events are attributed to the signed-in user");
}
return this._eventTracker.trackCustomEvent(eventType, data, options);
}
return this._trackServerEvent(eventType, data, options, options?.userId ?? null);
}
override startSpan(spanType: string, options?: StartSpanOptions & { userId?: string }): Span {
if (this._eventTracker) {
if (options?.userId !== undefined) {
console.error("Hexclave analytics: userId is only supported for server-key telemetry; in the browser, spans are attributed to the signed-in user");
return createInertSpan(spanType);
}
return this._eventTracker.startSpan(spanType, options);
}
return this._startServerSpan(spanType, options, options?.userId ?? null);
}
override setGlobalSpan(span: Span): void {
if (this._eventTracker) {
this._eventTracker.setGlobalSpan(span);
return;
}
if (span.isEnded) {
console.warn("Hexclave analytics: setGlobalSpan() called with an already-ended span; ignoring");
return;
}
this._serverGlobalSpans.add(span);
}
override unsetGlobalSpan(span: Span): void {
this._eventTracker?.unsetGlobalSpan(span);
this._serverGlobalSpans.delete(span);
}
override async flush(): Promise<void> {
await super.flush();
for (const key of [...this._serverTelemetryBuffers.keys()]) {
this._flushServerTelemetry(key);
}
await Promise.allSettled([...this._serverTelemetryInFlight]);
}
private _serverAmbientParentRefs(): SpanRef[] {
const refs: SpanRef[] = [];
for (const span of this._serverGlobalSpans) {
if (!span.isEnded) refs.push(span.ref());
}
return refs;
}
private _trackServerEvent(eventType: string, data: Record<string, unknown> | undefined, options: TrackOptions | undefined, userId: string | null): Promise<void> {
const nameError = getCustomTelemetryNameError("event", eventType);
if (nameError) return rejectedPreCaught(nameError);
const dataError = getCustomTelemetryDataError(data);
if (dataError) return rejectedPreCaught(dataError);
if (userId !== null && !SERVER_TELEMETRY_UUID_RE.test(userId)) {
return rejectedPreCaught(`Invalid userId ${JSON.stringify(userId)}: must be a user uuid`);
}
const resolved = resolveParentIds({ explicit: options?.parentIds, ambient: this._serverAmbientParentRefs() });
if ("error" in resolved) return rejectedPreCaught(resolved.error);
let settler!: TelemetrySettler;
const promise = new Promise<void>((resolve, reject) => {
settler = { resolve, reject };
});
promise.catch(() => {});
const buffer = this._getServerTelemetryBuffer(userId);
buffer.events.push({
event: {
event_type: eventType,
event_at_ms: Date.now(),
data: { ...data ?? {} },
...resolved.ids.length > 0 ? { parent_span_ids: resolved.ids } : {},
},
settler,
});
this._afterServerTelemetryEnqueue(userId, buffer);
return promise;
}
private _startServerSpan(spanType: string, options: StartSpanOptions | undefined, userId: string | null): Span {
const nameError = getCustomTelemetryNameError("span", spanType);
if (nameError) {
console.error(`Hexclave analytics: ${nameError}`);
return createInertSpan(spanType);
}
const dataError = getCustomTelemetryDataError(options?.data);
if (dataError) {
console.error(`Hexclave analytics: ${dataError}`);
return createInertSpan(spanType);
}
if (options?.startedAtMs !== undefined && (!Number.isInteger(options.startedAtMs) || options.startedAtMs < 0)) {
console.error("Hexclave analytics: startedAtMs must be a non-negative integer epoch-milliseconds value");
return createInertSpan(spanType);
}
if (userId !== null && !SERVER_TELEMETRY_UUID_RE.test(userId)) {
console.error(`Hexclave analytics: invalid userId ${JSON.stringify(userId)}: must be a user uuid`);
return createInertSpan(spanType);
}
const resolved = resolveParentIds({ explicit: options?.parentIds, ambient: this._serverAmbientParentRefs() });
if ("error" in resolved) {
console.error(`Hexclave analytics: ${resolved.error}`);
return createInertSpan(spanType);
}
const spanId = generateUuid();
const parentSpanIds = resolved.ids;
const startedAtMs = options?.startedAtMs ?? Date.now();
let accumulatedData: Record<string, unknown> = { ...options?.data ?? {} };
let lastVersion = 0;
let ended = false;
let endPromise: Promise<void> | null = null;
const nextVersion = () => (lastVersion = Math.max(Date.now(), lastVersion + 1));
const enqueue = (endedAtMs: number | null): Promise<void> => this._enqueueServerSpanUpdate(userId, {
span_id: spanId,
span_type: spanType,
started_at_ms: startedAtMs,
ended_at_ms: endedAtMs,
parent_span_ids: parentSpanIds,
data: { ...accumulatedData },
updated_at_ms: nextVersion(),
});
const span: Span = {
spanId,
spanType,
get isEnded() {
return ended;
},
setData: (data: Record<string, unknown>) => {
if (ended) return rejectedPreCaught(`setData() called on already-ended span "${spanType}"`);
const merged = { ...accumulatedData, ...data };
const mergedError = getCustomTelemetryDataError(merged);
if (mergedError) return rejectedPreCaught(mergedError);
accumulatedData = merged;
return enqueue(null);
},
end: (endOptions?: { endedAtMs?: number }) => {
if (endPromise) return endPromise;
ended = true;
this._serverGlobalSpans.delete(span);
const endedAtMs = Math.max(startedAtMs, Math.round(endOptions?.endedAtMs ?? Date.now()));
endPromise = enqueue(endedAtMs);
return endPromise;
},
trackEvent: (eventType: string, data?: Record<string, unknown>, trackOptions?: TrackOptions) =>
this._trackServerEvent(eventType, data, { ...trackOptions, parentIds: [span, ...trackOptions?.parentIds ?? []] }, userId),
startSpan: (childType: string, childOptions?: StartSpanOptions) =>
this._startServerSpan(childType, { ...childOptions, parentIds: [span, ...childOptions?.parentIds ?? []] }, userId),
ref: () => ({ spanId, parentSpanIds: [...parentSpanIds] }),
};
enqueue(null).catch(() => {});
return span;
}
private _enqueueServerSpanUpdate(userId: string | null, row: SpanUpdateRow): Promise<void> {
let settler!: TelemetrySettler;
const promise = new Promise<void>((resolve, reject) => {
settler = { resolve, reject };
});
promise.catch(() => {});
const buffer = this._getServerTelemetryBuffer(userId);
const previous = buffer.spans.get(row.span_id);
// Latest row per span id wins within a batch; superseded rows' settlers ride
// along so every returned promise settles with the batch that ships.
buffer.spans.set(row.span_id, { row, settlers: [...previous?.settlers ?? [], settler] });
this._afterServerTelemetryEnqueue(userId, buffer);
return promise;
}
private _getServerTelemetryBuffer(userId: string | null): ServerTelemetryBuffer {
let buffer = this._serverTelemetryBuffers.get(userId);
if (!buffer) {
buffer = { events: [], spans: new Map(), scheduled: false };
this._serverTelemetryBuffers.set(userId, buffer);
}
return buffer;
}
private _afterServerTelemetryEnqueue(userId: string | null, buffer: ServerTelemetryBuffer): void {
// Stay well under the server's 500-items-per-batch cap: ship immediately once
// the coalesced batch is large, otherwise wait for the microtask boundary.
if (buffer.events.length + buffer.spans.size >= SERVER_TELEMETRY_MAX_ITEMS_PER_BATCH) {
this._flushServerTelemetry(userId);
return;
}
if (buffer.scheduled) return;
buffer.scheduled = true;
queueMicrotask(() => this._flushServerTelemetry(userId));
}
private _flushServerTelemetry(userId: string | null): void {
const buffer = this._serverTelemetryBuffers.get(userId);
if (!buffer) return;
this._serverTelemetryBuffers.delete(userId);
const events = buffer.events;
const spanEntries = [...buffer.spans.values()];
if (events.length === 0 && spanEntries.length === 0) return;
const settlers: TelemetrySettler[] = events.map((entry) => entry.settler);
for (const entry of spanEntries) {
settlers.push(...entry.settlers);
}
const payload = {
batch_id: generateUuid(),
sent_at_ms: Date.now(),
...userId !== null ? { user_id: userId } : {},
...events.length > 0 ? { events: events.map((entry) => entry.event) } : {},
...spanEntries.length > 0 ? { spans: spanEntries.map((entry) => entry.row) } : {},
};
const send = (async () => {
try {
const res = await this._interface.sendAnalyticsEventBatchAsServer(JSON.stringify(payload));
if (res.status === "error") {
for (const settler of settlers) settler.reject(res.error);
console.warn("Hexclave analytics: server telemetry send failed:", res.error);
return;
}
if (!res.data.ok) {
const text = await res.data.text();
for (const settler of settlers) settler.reject(new Error(`Hexclave analytics: server telemetry send failed: ${res.data.status} ${text}`));
console.warn("Hexclave analytics: server telemetry send failed:", res.data.status, text);
return;
}
for (const settler of settlers) settler.resolve();
} catch (error) {
for (const settler of settlers) settler.reject(error);
console.warn("Hexclave analytics: server telemetry send failed:", error);
}
})();
const tracked: Promise<void> = send.finally(() => {
this._serverTelemetryInFlight.delete(tracked);
});
this._serverTelemetryInFlight.add(tracked);
}
}
const SERVER_TELEMETRY_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
// Below the route's 500-items cap with headroom, so a coalesced batch can never
// be rejected for size.
const SERVER_TELEMETRY_MAX_ITEMS_PER_BATCH = 400;
type TelemetrySettler = {
resolve: () => void,
reject: (error: unknown) => void,
};
type ServerTelemetryBuffer = {
events: {
event: {
event_type: string,
event_at_ms: number,
data: Record<string, unknown>,
parent_span_ids?: string[],
},
settler: TelemetrySettler,
}[],
spans: Map<string, { row: SpanUpdateRow, settlers: TelemetrySettler[] }>,
scheduled: boolean,
};

View File

@ -7,6 +7,7 @@ import { CustomerInvoicesList, CustomerInvoicesRequestOptions, CustomerProductsL
import { Project } from "../../projects";
import { ProjectCurrentUser, SyncedPartialUser, TokenPartialUser } from "../../users";
import { _HexclaveClientAppImpl } from "../implementations";
import type { Span, StartSpanOptions, TrackOptions } from "../implementations/event-tracker";
import { AnalyticsOptions } from "../implementations/session-replay";
/** @deprecated Use `HexclaveClientAppConstructorOptions` from the `@hexclave/*` package instead — same symbol, new brand name. See https://docs.hexclave.com/migration. */
@ -112,6 +113,38 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
cancelSubscription(options: { productId: string, subscriptionId?: string } | { productId: string, subscriptionId?: string, teamId: string }): Promise<void>,
/**
* Tracks a custom analytics event. Buffered and sent in batches. The
* returned promise resolves when the batch carrying the event is
* acknowledged (up to one flush interval later call `flush()` to send
* immediately) and is safe to ignore. Never throws; invalid input yields a
* rejected (pre-caught) promise. No-ops outside the browser or when
* analytics is disabled.
*/
trackEvent(eventType: string, data?: Record<string, unknown>, options?: TrackOptions): Promise<void>,
/**
* Starts a custom span (a time interval). The span is written immediately
* as an open interval and re-written when data changes or it ends a span
* that is never ended (e.g. the tab closed) stays visible as an open
* interval. Never throws; returns an inert no-op span outside the browser,
* when analytics is disabled, or on invalid input.
*/
startSpan(spanType: string, options?: StartSpanOptions): Span,
/**
* Registers a span as an ambient parent for all subsequently tracked
* custom events and spans (additive with explicit `parentIds`). Ending the
* span automatically unregisters it.
*/
setGlobalSpan(span: Span): void,
unsetGlobalSpan(span: Span): void,
/**
* Sends all buffered analytics immediately and settles in-flight sends.
*/
flush(): Promise<void>,
// note: we don't special-case 'anonymous' here to return non-null, see GetPartialUserOptions for more details
getPartialUser(options: GetCurrentPartialUserOptions<HasTokenStore> & { from: 'token' }): Promise<TokenPartialUser | null>,
getPartialUser(options: GetCurrentPartialUserOptions<HasTokenStore> & { from: 'convex' }): Promise<TokenPartialUser | null>,

View File

@ -8,6 +8,7 @@ import { EmailDeliveryInfo, SendEmailOptions } from "../../email";
import { ServerListTeamsOptions, ServerListUsersOptions, ServerTeam, ServerTeamCreateOptions } from "../../teams";
import { ProjectCurrentServerUser, ServerOAuthProvider, ServerUser, ServerUserCreateOptions, SyncedPartialServerUser, TokenPartialUser } from "../../users";
import { _HexclaveServerAppImpl } from "../implementations";
import type { Span, StartSpanOptions, TrackOptions } from "../implementations/event-tracker";
import { StackClientApp, StackClientAppConstructorOptions } from "./client-app";
@ -37,6 +38,20 @@ export type StackServerApp<HasTokenStore extends boolean = boolean, ProjectId ex
{ returnUrl?: string }
)): Promise<string>,
/**
* Server-side variant of `trackEvent`: attribution is explicit via `userId`
* (there is no session to derive it from). Items coalesce per userId and
* send on the next microtask; `await` the promise (or call `flush()`) as the
* delivery guarantee the server has no page-lifetime flush cadence.
*/
trackEvent(eventType: string, data?: Record<string, unknown>, options?: TrackOptions & { userId?: string }): Promise<void>,
/**
* Server-side variant of `startSpan`: attribution is explicit via `userId`.
* Child spans and span-attached events inherit the span's userId.
*/
startSpan(spanType: string, options?: StartSpanOptions & { userId?: string }): Span,
// IF_PLATFORM react-like
useUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'redirect' }): ProjectCurrentServerUser<ProjectId>,
useUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'throw' }): ProjectCurrentServerUser<ProjectId>,