Analytics event tracking (#1208)

<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Browser-side event tracker with batching, navigation & click capture
and background/keepalive delivery
* Server endpoint to accept batched analytics events and associate them
with session replay segments
* Client APIs to send analytics batches and integrate with session
replay

* **Bug Fixes / UX**
* Pausing replay now uses the UI-facing playback time for more accurate
pause positions
* Replay endpoint now returns a clear analytics-disabled error
(ANALYTICS_NOT_ENABLED) when analytics is off

* **Tests**
* End-to-end tests covering batch ingestion, validation, and replay
timing behavior
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
BilalG1
2026-02-17 18:33:01 -08:00
committed by GitHub
parent fd79f626d3
commit 145bcb7e92
17 changed files with 939 additions and 43 deletions
@@ -36,6 +36,7 @@ export type ClientInterfaceOptions = {
clientVersion: string,
// This is a function instead of a string because it might be different based on the environment (for example client vs server)
getBaseUrl: () => string,
getAnalyticsBaseUrl?: () => string,
extraRequestHeaders: Record<string, string>,
projectId: string,
prepareRequest?: () => Promise<void>,
@@ -60,6 +61,10 @@ export class StackClientInterface {
return this.options.getBaseUrl() + "/api/v1";
}
getAnalyticsApiUrl() {
return (this.options.getAnalyticsBaseUrl ?? this.options.getBaseUrl)() + "/api/v1";
}
public async runNetworkDiagnostics(session?: InternalSession | null, requestType?: "client" | "server" | "admin") {
if (this.pendingNetworkDiagnostics) {
return await this.pendingNetworkDiagnostics;
@@ -224,13 +229,14 @@ export class StackClientInterface {
requestOptions: RequestInit,
session: InternalSession | null,
requestType: "client" | "server" | "admin" = "client",
apiUrlOverride?: string,
) {
session ??= this.createSession({
refreshToken: null,
});
return await this._networkRetry(
() => this.sendClientRequestInner(path, requestOptions, session!, requestType),
() => this.sendClientRequestInner(path, requestOptions, session!, requestType, apiUrlOverride),
session,
requestType,
);
@@ -259,6 +265,32 @@ export class StackClientInterface {
keepalive: options.keepalive,
},
session,
"client",
this.getAnalyticsApiUrl(),
);
return Result.ok(response);
} catch (e) {
return Result.error(e instanceof Error ? e : new Error(String(e)));
}
}
async sendAnalyticsEventBatch(
body: string,
session: InternalSession | null,
options: { keepalive: boolean },
): Promise<Result<Response, Error>> {
try {
const response = await this.sendClientRequest(
"/analytics/events/batch",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body,
keepalive: options.keepalive,
},
session,
"client",
this.getAnalyticsApiUrl(),
);
return Result.ok(response);
} catch (e) {
@@ -297,6 +329,7 @@ export class StackClientInterface {
options: RequestInit,
session: InternalSession,
requestType: "client" | "server" | "admin",
apiUrlOverride?: string,
): Promise<Result<Response & {
usedTokens: {
accessToken: AccessToken,
@@ -331,7 +364,7 @@ export class StackClientInterface {
// all requests should be dynamic to prevent Next.js caching
await this.options.prepareRequest?.();
let url = this.getApiUrl() + path;
let url = (apiUrlOverride ?? this.getApiUrl()) + path;
if (url.endsWith("/")) {
url = url.slice(0, -1);
}
@@ -1736,6 +1736,16 @@ const AnalyticsQueryError = createKnownErrorConstructor(
(json) => [json.error] as const,
);
const AnalyticsNotEnabled = createKnownErrorConstructor(
KnownError,
"ANALYTICS_NOT_ENABLED",
() => [
400,
"Analytics is not enabled for this project.",
] as const,
() => [] as const,
);
const DefaultPaymentMethodRequired = createKnownErrorConstructor(
KnownError,
"DEFAULT_PAYMENT_METHOD_REQUIRED",
@@ -1900,6 +1910,7 @@ export const KnownErrors = {
DataVaultStoreHashedKeyDoesNotExist,
AnalyticsQueryTimeout,
AnalyticsQueryError,
AnalyticsNotEnabled,
} satisfies Record<string, KnownErrorConstructor<any, any>>;