mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
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:
@@ -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>>;
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user