diff --git a/apps/backend/.env.development b/apps/backend/.env.development index 8b9329ce29..aa5565cfb3 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -76,6 +76,10 @@ STACK_OPENAI_API_KEY=mock_openai_api_key STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret STACK_OPENROUTER_API_KEY=FORWARD_TO_PRODUCTION +# Optional: override docs origin for the `docs` AI tool bundle (defaults to http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}04 in dev, https://mcp.stack-auth.com in prod) +# STACK_DOCS_INTERNAL_BASE_URL=http://localhost:8104 +# Optional: shared secret; when set, backend sends it and docs `/api/internal/docs-tools` requires it +# STACK_INTERNAL_DOCS_TOOLS_SECRET= # Email monitor configuration for tests STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL=http://localhost:8101/handler/email-verification STACK_EMAIL_MONITOR_PROJECT_ID=internal diff --git a/apps/backend/scripts/clickhouse-migrations.ts b/apps/backend/scripts/clickhouse-migrations.ts index d94f84baa0..a1b3fe30a0 100644 --- a/apps/backend/scripts/clickhouse-migrations.ts +++ b/apps/backend/scripts/clickhouse-migrations.ts @@ -17,16 +17,26 @@ export async function runClickhouseMigrations() { await client.exec({ query: USERS_TABLE_BASE_SQL }); await client.exec({ query: USERS_VIEW_SQL }); await client.exec({ query: EVENTS_ADD_REPLAY_COLUMNS_SQL }); + await client.exec({ query: EVENTS_ADD_EVENT_ID_COLUMNS_SQL }); await client.exec({ query: TOKEN_REFRESH_EVENT_ROW_FORMAT_MUTATION_SQL }); await client.exec({ query: BACKFILL_REFRESH_TOKEN_ID_COLUMN_SQL }); await client.exec({ query: SIGN_UP_RULE_TRIGGER_EVENT_ROW_FORMAT_MUTATION_SQL }); - // Recreate the events view so SELECT * picks up columns added by EVENTS_ADD_REPLAY_COLUMNS_SQL + await client.exec({ query: SPANS_TABLE_SQL }); + await client.exec({ query: SPANS_VIEW_SQL }); + await client.exec({ query: EVENTS_ADD_FROM_SERVER_COLUMN_SQL }); + await client.exec({ query: SPANS_ADD_FROM_SERVER_COLUMN_SQL }); + await client.exec({ query: EVENTS_ADD_TRACE_ID_COLUMN_SQL }); + await client.exec({ query: SPANS_ADD_TRACE_ID_COLUMN_SQL }); + // Recreate the events view so SELECT * picks up columns added by migrations await client.exec({ query: EVENTS_VIEW_SQL }); + // Recreate the spans view so SELECT * picks up columns added by migrations + await client.exec({ query: SPANS_VIEW_SQL }); const queries = [ "REVOKE ALL PRIVILEGES ON *.* FROM limited_user;", "REVOKE ALL FROM limited_user;", "GRANT SELECT ON default.events TO limited_user;", "GRANT SELECT ON default.users TO limited_user;", + "GRANT SELECT ON default.spans TO limited_user;", ]; await client.exec({ query: "CREATE ROW POLICY IF NOT EXISTS events_project_isolation ON default.events FOR SELECT USING project_id = getSetting('SQL_project_id') AND branch_id = getSetting('SQL_branch_id') TO limited_user", @@ -34,6 +44,9 @@ export async function runClickhouseMigrations() { await client.exec({ query: "CREATE ROW POLICY IF NOT EXISTS users_project_isolation ON default.users FOR SELECT USING project_id = getSetting('SQL_project_id') AND branch_id = getSetting('SQL_branch_id') TO limited_user", }); + await client.exec({ + query: "CREATE ROW POLICY IF NOT EXISTS spans_project_isolation ON default.spans FOR SELECT USING project_id = getSetting('SQL_project_id') AND branch_id = getSetting('SQL_branch_id') TO limited_user", + }); for (const query of queries) { await client.exec({ query }); } @@ -50,7 +63,7 @@ CREATE TABLE IF NOT EXISTS analytics_internal.events ( branch_id String, user_id Nullable(String), team_id Nullable(String), - created_at DateTime64(3, 'UTC') DEFAULT now64(3) + created_at DateTime64(3, 'UTC') DEFAULT now64(3) ) ENGINE MergeTree PARTITION BY toYYYYMM(event_at) @@ -58,7 +71,7 @@ ORDER BY (project_id, branch_id, event_at); `; const EVENTS_VIEW_SQL = ` -CREATE OR REPLACE VIEW default.events +CREATE OR REPLACE VIEW default.events SQL SECURITY DEFINER AS SELECT * @@ -146,7 +159,7 @@ ORDER BY (project_id, branch_id, id); `; const USERS_VIEW_SQL = ` -CREATE OR REPLACE VIEW default.users +CREATE OR REPLACE VIEW default.users SQL SECURITY DEFINER AS SELECT @@ -197,6 +210,63 @@ WHERE event_type = '$token-refresh' AND data.refresh_token_id::Nullable(String) IS NOT NULL; `; +const EVENTS_ADD_EVENT_ID_COLUMNS_SQL = ` +ALTER TABLE analytics_internal.events + ADD COLUMN IF NOT EXISTS event_id String DEFAULT '' AFTER event_type, + ADD COLUMN IF NOT EXISTS parent_span_ids Array(String) DEFAULT [] AFTER event_id; +`; + +const SPANS_TABLE_SQL = ` +CREATE TABLE IF NOT EXISTS analytics_internal.spans ( + span_type LowCardinality(String), + span_id String, + started_at DateTime64(3, 'UTC'), + created_at DateTime64(3, 'UTC') DEFAULT now64(3), + ended_at Nullable(DateTime64(3, 'UTC')), + parent_ids Array(String) DEFAULT [], + data JSON, + project_id String, + branch_id String, + user_id Nullable(String), + team_id Nullable(String), + refresh_token_id Nullable(String), + session_replay_id Nullable(String), + session_replay_segment_id Nullable(String) +) +ENGINE ReplacingMergeTree(created_at) +PARTITION BY toYYYYMM(started_at) +ORDER BY (project_id, branch_id, span_id); +`; + +const SPANS_VIEW_SQL = ` +CREATE OR REPLACE VIEW default.spans +SQL SECURITY DEFINER +AS +SELECT * +FROM analytics_internal.spans +FINAL; +`; + +const EVENTS_ADD_FROM_SERVER_COLUMN_SQL = ` +ALTER TABLE analytics_internal.events + ADD COLUMN IF NOT EXISTS from_server Bool DEFAULT false AFTER session_replay_segment_id; +`; + +const SPANS_ADD_FROM_SERVER_COLUMN_SQL = ` +ALTER TABLE analytics_internal.spans + ADD COLUMN IF NOT EXISTS from_server Bool DEFAULT false AFTER session_replay_segment_id; +`; + +const EVENTS_ADD_TRACE_ID_COLUMN_SQL = ` +ALTER TABLE analytics_internal.events + ADD COLUMN IF NOT EXISTS trace_id Nullable(String) AFTER event_id; +`; + +const SPANS_ADD_TRACE_ID_COLUMN_SQL = ` +ALTER TABLE analytics_internal.spans + ADD COLUMN IF NOT EXISTS trace_id Nullable(String) AFTER span_id; +`; + const EXTERNAL_ANALYTICS_DB_SQL = ` CREATE DATABASE IF NOT EXISTS analytics_internal; `; diff --git a/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts b/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts index aded240d53..ea75c58423 100644 --- a/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts +++ b/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts @@ -129,7 +129,7 @@ export const POST = createSmartRouteHandler({ return { statusCode: 200, bodyType: "json" as const, - body: { content: contentBlocks }, + body: { content: contentBlocks, finalText: result.text }, }; } }, diff --git a/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx b/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx index d1403c87fc..a5862a3ab6 100644 --- a/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx +++ b/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx @@ -1,43 +1,20 @@ -import { getClickhouseAdminClient } from "@/lib/clickhouse"; +import { isValidClientAnalyticsEventType, isValidPublicAnalyticsEventType, PUBLIC_STACK_ANALYTICS_EVENT_TYPE_LIST, UUID_RE } from "@/lib/analytics-validation"; +import { insertAnalyticsEvents } from "@/lib/events"; import { findRecentSessionReplay } from "@/lib/session-replays"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared"; -import { adaptSchema, clientOrHigherAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { adaptSchema, clientOrHigherAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; - -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; +import { randomUUID } from "node:crypto"; const MAX_EVENTS = 500; -// 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])|(? [k, stripLoneSurrogates(v)]) - ); - } - return value; -} - export const POST = createSmartRouteHandler({ metadata: { summary: "Upload analytics event batch", - description: "Uploads a batch of auto-captured analytics events ($page-view, $click).", + description: "Uploads a batch of Stack-managed browser analytics events or custom analytics events.", tags: ["Analytics Events"], - hidden: true, }, request: yupObject({ auth: yupObject({ @@ -47,14 +24,26 @@ export const POST = createSmartRouteHandler({ refreshTokenId: adaptSchema, }).defined(), body: yupObject({ - session_replay_segment_id: yupString().defined().matches(UUID_RE, "Invalid session_replay_segment_id"), + session_replay_id: yupString().optional().matches(UUID_RE, "Invalid session_replay_id"), + 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), events: yupArray( yupObject({ - event_type: yupString().defined().oneOf(["$page-view", "$click"]), + event_type: yupString().defined().test( + "analytics-event-type", + `event_type must be ${PUBLIC_STACK_ANALYTICS_EVENT_TYPE_LIST}, or a custom event name that does not start with "$" and only contains letters, numbers, ".", "_", ":", or "-"`, + (value) => isValidPublicAnalyticsEventType(value), + ), event_at_ms: yupNumber().defined().integer().min(0), - data: yupMixed().defined(), + data: yupObject({}).defined().unknown(true), + user_id: yupString().uuid().optional(), + team_id: yupString().uuid().optional(), + event_id: yupString().optional().matches(UUID_RE, "Invalid event_id"), + trace_id: yupString().optional().matches(UUID_RE, "Invalid trace_id"), + parent_span_ids: yupArray(yupString().defined().matches(UUID_RE, "Invalid parent_span_id")).optional().max(20), + session_replay_id: yupString().optional().matches(UUID_RE, "Invalid session_replay_id"), + session_replay_segment_id: yupString().optional().matches(UUID_RE, "Invalid session_replay_segment_id"), }).defined(), ).defined().min(1).max(MAX_EVENTS), }).defined(), @@ -70,47 +59,101 @@ export const POST = createSmartRouteHandler({ if (!auth.tenancy.config.apps.installed["analytics"]?.enabled) { throw new KnownErrors.AnalyticsNotEnabled(); } - if (!auth.user) { + + if (auth.type === "client" && !auth.user) { throw new KnownErrors.UserAuthenticationRequired(); } - if (!auth.refreshTokenId) { + if (auth.type === "client" && !auth.refreshTokenId) { throw new StatusError(StatusError.BadRequest, "A refresh token is required for analytics events"); } + if (auth.type === "client" && body.events.some((event) => event.user_id != null || event.team_id != null)) { + throw new StatusError(StatusError.BadRequest, "Client analytics events cannot override user_id or team_id"); + } + if (auth.type === "client" && body.events.some((event) => !isValidClientAnalyticsEventType(event.event_type))) { + throw new StatusError(StatusError.BadRequest, "Client analytics events cannot use server-only event types"); + } 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 clientUserId = auth.type === "client" ? auth.user?.id ?? null : null; + if (auth.type === "client" && clientUserId === null) { + throw new KnownErrors.UserAuthenticationRequired(); + } + const defaultUserId = auth.user?.id ?? null; - const prisma = await getPrismaClientForTenancy(auth.tenancy); - const recentSession = await findRecentSessionReplay(prisma, { tenancyId, refreshTokenId }); + const explicitSessionReplayIds = [...new Set([ + body.session_replay_id, + ...body.events.map((event) => event.session_replay_id), + ].filter((sessionReplayId): sessionReplayId is string => sessionReplayId != null))]; - const clickhouseClient = getClickhouseAdminClient(); + const needsFallbackSessionReplayId = body.session_replay_id == null || body.events.some((event) => event.session_replay_id == null); + const shouldLoadPrisma = explicitSessionReplayIds.length > 0 || (auth.refreshTokenId != null && needsFallbackSessionReplayId); + const prisma = shouldLoadPrisma ? await getPrismaClientForTenancy(auth.tenancy) : null; - const rows = body.events.map((event) => ({ - event_type: event.event_type, - event_at: new Date(event.event_at_ms), - data: stripLoneSurrogates(event.data), - project_id: projectId, - branch_id: branchId, - 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, - })); + if (prisma && explicitSessionReplayIds.length > 0) { + const explicitSessionReplays = await prisma.sessionReplay.findMany({ + where: { + tenancyId: auth.tenancy.id, + id: { in: explicitSessionReplayIds }, + }, + select: { + id: true, + refreshTokenId: true, + }, + }); - await clickhouseClient.insert({ - table: "analytics_internal.events", - values: rows, - format: "JSONEachRow", - clickhouse_settings: { - date_time_input_format: "best_effort", - async_insert: 1, - }, + const explicitSessionReplayIdsFound = new Set(explicitSessionReplays.map((sessionReplay) => sessionReplay.id)); + const missingSessionReplayId = explicitSessionReplayIds.find((sessionReplayId) => !explicitSessionReplayIdsFound.has(sessionReplayId)); + if (missingSessionReplayId != null) { + throw new StatusError(StatusError.BadRequest, `Unknown session_replay_id: ${missingSessionReplayId}`); + } + + if (auth.type === "client") { + const invalidClientSessionReplay = explicitSessionReplays.find((sessionReplay) => sessionReplay.refreshTokenId !== auth.refreshTokenId); + if (invalidClientSessionReplay != null) { + throw new StatusError(StatusError.BadRequest, "Client analytics events can only reference the current session replay"); + } + } + } + + let recentSessionReplayId: string | null = null; + if (prisma && auth.refreshTokenId && needsFallbackSessionReplayId) { + const recentSession = await findRecentSessionReplay(prisma, { + tenancyId: auth.tenancy.id, + refreshTokenId: auth.refreshTokenId, + }); + recentSessionReplayId = recentSession?.id ?? null; + } + + const rows = body.events.map((event) => { + const sessionReplayId = event.session_replay_id ?? body.session_replay_id ?? recentSessionReplayId; + const sessionReplaySegmentId = event.session_replay_segment_id ?? body.session_replay_segment_id ?? null; + const explicitParentSpanIds = event.parent_span_ids ?? []; + // Auto-link events to their segment span (segment_id IS the span_id) + const parentSpanIds = sessionReplaySegmentId && !explicitParentSpanIds.includes(sessionReplaySegmentId) + ? [sessionReplaySegmentId, ...explicitParentSpanIds] + : explicitParentSpanIds; + + return { + event_type: event.event_type, + event_id: event.event_id || randomUUID(), + trace_id: event.trace_id ?? null, + event_at: new Date(event.event_at_ms), + parent_span_ids: parentSpanIds, + data: event.data, + project_id: projectId, + branch_id: branchId, + user_id: auth.type === "client" ? clientUserId : event.user_id ?? defaultUserId, + team_id: auth.type === "client" ? null : event.team_id ?? null, + refresh_token_id: auth.refreshTokenId ?? null, + session_replay_id: sessionReplayId, + session_replay_segment_id: sessionReplaySegmentId, + from_server: auth.type !== "client", + }; }); + await insertAnalyticsEvents(rows); + return { statusCode: 200, bodyType: "json", diff --git a/apps/backend/src/app/api/latest/analytics/otlp/v1/traces/route.tsx b/apps/backend/src/app/api/latest/analytics/otlp/v1/traces/route.tsx new file mode 100644 index 0000000000..740e10034a --- /dev/null +++ b/apps/backend/src/app/api/latest/analytics/otlp/v1/traces/route.tsx @@ -0,0 +1,187 @@ +import { isValidPublicAnalyticsSpanType } from "@/lib/analytics-validation"; +import { insertSpans } from "@/lib/spans"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { adaptSchema, serverOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; + +/** + * OTLP/HTTP trace ingest endpoint. + * + * Accepts the standard OTLP JSON encoding (`application/json`) as defined by + * https://opentelemetry.io/docs/specs/otlp/#otlphttp-request + * + * Only server or admin auth is supported (not client auth) because OTLP traces + * typically originate from backend services. + */ + +const MAX_SPANS = 500; +const SPAN_TYPE_RE = /[^A-Za-z0-9._:-]/g; + +function sanitizeSpanType(name: string): string { + const sanitized = name.replace(/[\s/]/g, ".").replace(SPAN_TYPE_RE, ""); + return sanitized.length > 0 ? `otel.${sanitized}` : "otel.unknown"; +} + +function nanosToDate(nanos: string | number): Date { + const ns = typeof nanos === "string" ? BigInt(nanos) : BigInt(nanos); + return new Date(Number(ns / 1_000_000n)); +} + +type OtlpAttribute = { key: string, value: OtlpAnyValue }; +type OtlpAnyValue = { + stringValue?: string, + intValue?: string | number, + doubleValue?: number, + boolValue?: boolean, + arrayValue?: { values: OtlpAnyValue[] }, + kvlistValue?: { values: OtlpAttribute[] }, +}; + +function extractAttributeValue(anyValue: OtlpAnyValue): unknown { + if (anyValue.stringValue !== undefined) return anyValue.stringValue; + if (anyValue.intValue !== undefined) return typeof anyValue.intValue === "string" ? parseInt(anyValue.intValue, 10) : anyValue.intValue; + if (anyValue.doubleValue !== undefined) return anyValue.doubleValue; + if (anyValue.boolValue !== undefined) return anyValue.boolValue; + if (anyValue.arrayValue) return anyValue.arrayValue.values.map(extractAttributeValue); + if (anyValue.kvlistValue) { + const obj: Record = {}; + for (const attr of anyValue.kvlistValue.values) { + obj[attr.key] = extractAttributeValue(attr.value); + } + return obj; + } + return null; +} + +function attributesToMap(attributes?: OtlpAttribute[]): Record { + if (!attributes) return {}; + const result: Record = {}; + for (const attr of attributes) { + result[attr.key] = extractAttributeValue(attr.value); + } + return result; +} + +const STATUS_NAMES = ["unset", "ok", "error"] as const; +const KIND_NAMES = ["unspecified", "internal", "server", "client", "producer", "consumer"] as const; + +export const POST = createSmartRouteHandler({ + metadata: { + summary: "OTLP trace ingest", + description: "Accepts OpenTelemetry traces in OTLP/HTTP JSON encoding and inserts them as analytics spans.", + tags: ["Analytics Spans"], + }, + request: yupObject({ + auth: yupObject({ + type: serverOrHigherAuthTypeSchema, + tenancy: adaptSchema, + }).defined(), + body: adaptSchema, + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + inserted: yupNumber().defined(), + }).defined(), + }), + async handler({ auth, body }) { + if (!auth.tenancy.config.apps.installed["analytics"]?.enabled) { + throw new KnownErrors.AnalyticsNotEnabled(); + } + + const projectId = auth.tenancy.project.id; + const branchId = auth.tenancy.branchId; + + const resourceSpans = (body as any)?.resourceSpans; + if (!Array.isArray(resourceSpans)) { + throw new StatusError(StatusError.BadRequest, "Invalid OTLP payload: missing resourceSpans array"); + } + + const rows: Parameters[0] = []; + + for (const rs of resourceSpans) { + const resourceAttrs = attributesToMap(rs.resource?.attributes); + const scopeSpans = rs.scopeSpans ?? rs.instrumentationLibrarySpans ?? []; + + for (const ss of scopeSpans) { + const spans = ss.spans ?? []; + + for (const span of spans) { + if (rows.length >= MAX_SPANS) break; + + const traceId = span.traceId as string | undefined; + const spanId = span.spanId as string | undefined; + const parentSpanId = span.parentSpanId as string | undefined; + const name = span.name as string | undefined; + + if (!spanId || !name) continue; + + const spanType = sanitizeSpanType(name); + if (!isValidPublicAnalyticsSpanType(spanType)) continue; + + const statusCode = span.status?.code ?? 0; + const attributes = attributesToMap(span.attributes); + const data: Record = { + ...attributes, + $status: STATUS_NAMES[statusCode] ?? "unset", + "otel.kind": KIND_NAMES[span.kind ?? 0] ?? "unspecified", + "otel.trace_id": traceId, + "otel.span_id": spanId, + }; + + if (span.status?.message) { + data.$status_message = span.status.message; + } + + if (Array.isArray(span.events) && span.events.length > 0) { + data["otel.events"] = span.events.map((e: any) => ({ + name: e.name, + time_ms: e.timeUnixNano ? Number(BigInt(e.timeUnixNano) / 1_000_000n) : 0, + attributes: attributesToMap(e.attributes), + })); + } + + for (const [key, value] of Object.entries(resourceAttrs)) { + data[`otel.resource.${key}`] = value; + } + + rows.push({ + span_type: spanType, + span_id: spanId, + trace_id: traceId ?? null, + started_at: span.startTimeUnixNano ? nanosToDate(span.startTimeUnixNano) : new Date(), + ended_at: span.endTimeUnixNano ? nanosToDate(span.endTimeUnixNano) : null, + parent_ids: parentSpanId ? [parentSpanId] : [], + data, + project_id: projectId, + branch_id: branchId, + user_id: null, + team_id: null, + refresh_token_id: null, + session_replay_id: null, + session_replay_segment_id: null, + from_server: true, + }); + } + } + } + + if (rows.length === 0) { + return { + statusCode: 200, + bodyType: "json" as const, + body: { inserted: 0 }, + }; + } + + await insertSpans(rows); + + return { + statusCode: 200, + bodyType: "json" as const, + body: { inserted: rows.length }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/analytics/spans/batch/route.tsx b/apps/backend/src/app/api/latest/analytics/spans/batch/route.tsx new file mode 100644 index 0000000000..0338e9faa2 --- /dev/null +++ b/apps/backend/src/app/api/latest/analytics/spans/batch/route.tsx @@ -0,0 +1,93 @@ +import { isValidPublicAnalyticsSpanType, UUID_RE } from "@/lib/analytics-validation"; +import { insertSpans } from "@/lib/spans"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { adaptSchema, clientOrHigherAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; + +const MAX_SPANS = 200; + +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Upload analytics span batch", + description: "Uploads a batch of span records (timed operations) for analytics.", + tags: ["Analytics Spans"], + }, + request: yupObject({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema, + tenancy: adaptSchema, + user: adaptSchema, + refreshTokenId: adaptSchema, + }).defined(), + body: yupObject({ + batch_id: yupString().defined().matches(UUID_RE, "Invalid batch_id"), + sent_at_ms: yupNumber().defined().integer().min(0), + spans: yupArray( + yupObject({ + span_type: yupString().defined().test( + "analytics-span-type", + 'span_type must not start with "$" and may only contain letters, numbers, ".", "_", ":", or "-"', + (value) => isValidPublicAnalyticsSpanType(value), + ), + span_id: yupString().defined().matches(UUID_RE, "Invalid span_id"), + trace_id: yupString().optional().matches(UUID_RE, "Invalid trace_id"), + started_at_ms: yupNumber().defined().integer().min(0), + ended_at_ms: yupNumber().optional().nullable().integer().min(0), + parent_ids: yupArray(yupString().defined().matches(UUID_RE, "Invalid parent_id")).optional().max(20), + data: yupObject({}).defined().unknown(true), + user_id: yupString().uuid().optional(), + team_id: yupString().uuid().optional(), + session_replay_id: yupString().optional().matches(UUID_RE, "Invalid session_replay_id"), + session_replay_segment_id: yupString().optional().matches(UUID_RE, "Invalid session_replay_segment_id"), + }).defined(), + ).defined().min(1).max(MAX_SPANS), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + inserted: yupNumber().defined(), + }).defined(), + }), + async handler({ auth, body }) { + if (!auth.tenancy.config.apps.installed["analytics"]?.enabled) { + throw new KnownErrors.AnalyticsNotEnabled(); + } + + const projectId = auth.tenancy.project.id; + const branchId = auth.tenancy.branchId; + const defaultUserId = auth.user?.id ?? null; + + if (auth.type === "client" && body.spans.some((span) => span.user_id != null || span.team_id != null)) { + throw new StatusError(StatusError.BadRequest, "Client analytics spans cannot override user_id or team_id"); + } + + const rows = body.spans.map((span) => ({ + span_type: span.span_type, + span_id: span.span_id, + trace_id: span.trace_id ?? null, + started_at: new Date(span.started_at_ms), + ended_at: span.ended_at_ms != null ? new Date(span.ended_at_ms) : null, + parent_ids: span.parent_ids ?? [], + data: span.data, + project_id: projectId, + branch_id: branchId, + user_id: auth.type === "client" ? defaultUserId : (span.user_id ?? defaultUserId), + team_id: auth.type === "client" ? null : (span.team_id ?? null), + refresh_token_id: auth.refreshTokenId ?? null, + session_replay_id: span.session_replay_id ?? null, + session_replay_segment_id: span.session_replay_segment_id ?? null, + from_server: auth.type !== "client", + })); + + await insertSpans(rows); + + return { + statusCode: 200, + bodyType: "json", + body: { inserted: body.spans.length }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/session-replays/batch/route.tsx b/apps/backend/src/app/api/latest/session-replays/batch/route.tsx index 57e1a162e4..513a37beec 100644 --- a/apps/backend/src/app/api/latest/session-replays/batch/route.tsx +++ b/apps/backend/src/app/api/latest/session-replays/batch/route.tsx @@ -1,8 +1,10 @@ +import { UUID_RE } from "@/lib/analytics-validation"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { uploadBytes } from "@/s3"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { Prisma } from "@/generated/prisma/client"; import { findRecentSessionReplay } from "@/lib/session-replays"; +import { insertSpans } from "@/lib/spans"; import { KnownErrors } from "@stackframe/stack-shared"; import { adaptSchema, clientOrHigherAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; @@ -12,8 +14,6 @@ import { gzip as gzipCb } from "node:zlib"; const gzip = promisify(gzipCb); -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; - const MAX_BODY_BYTES = 1_000_000; const MAX_EVENTS = 5_000; @@ -127,6 +127,44 @@ export const POST = createSmartRouteHandler({ }, }); + // Upsert spans for the replay and segment (ReplacingMergeTree deduplicates by span_id, keeping latest created_at) + await insertSpans([ + { + span_type: "$session-replay", + span_id: replayId, + trace_id: replayId, + started_at: new Date(newStartedAtMs), + ended_at: new Date(newLastEventAtMs), + parent_ids: [], + data: {}, + project_id: projectId, + branch_id: branchId, + user_id: projectUserId, + team_id: null, + refresh_token_id: refreshTokenId, + session_replay_id: replayId, + session_replay_segment_id: null, + from_server: auth.type !== "client", + }, + { + span_type: "$session-replay-segment", + span_id: sessionReplaySegmentId, + trace_id: replayId, + started_at: new Date(firstMs), + ended_at: new Date(lastMs), + parent_ids: [replayId], + data: {}, + project_id: projectId, + branch_id: branchId, + user_id: projectUserId, + team_id: null, + refresh_token_id: refreshTokenId, + session_replay_id: replayId, + session_replay_segment_id: sessionReplaySegmentId, + from_server: auth.type !== "client", + }, + ]); + // If we already have this batch for this session, return deduped without touching S3. const existingChunk = await prisma.sessionReplayChunk.findUnique({ where: { tenancyId_sessionReplayId_batchId: { tenancyId, sessionReplayId: replayId, batchId } }, diff --git a/apps/backend/src/lib/ai/prompts.ts b/apps/backend/src/lib/ai/prompts.ts index 1ce70b5af1..96f53ef825 100644 --- a/apps/backend/src/lib/ai/prompts.ts +++ b/apps/backend/src/lib/ai/prompts.ts @@ -74,7 +74,7 @@ Run a ClickHouse SQL query against the project's analytics database. Only SELECT Available tables: **events** - User activity events -- event_type: LowCardinality(String) - $token-refresh is the only valid event_type right now, it occurs whenever an access token is refreshed +- event_type: LowCardinality(String) - Event name. Stack-managed events use a \`$\` prefix (for example \`$token-refresh\`, \`$page-view\`, \`$click\`, \`$tab-in\`, \`$tab-out\`, \`$window-focus\`, \`$window-blur\`, \`$submit\`, \`$scroll-depth\`, \`$rage-click\`, \`$copy\`, \`$paste\`, \`$error\`), and projects may also send custom event names without a \`$\` prefix - event_at: DateTime64(3, 'UTC') - When the event occurred - data: JSON - Additional event data - user_id: Nullable(String) - Associated user ID @@ -646,7 +646,7 @@ CLICKHOUSE (queryAnalytics only) Available tables: events: -- event_type: LowCardinality(String) ($token-refresh only) +- event_type: LowCardinality(String) (Stack-managed \`$...\` events plus custom event names without a \`$\` prefix) - event_at: DateTime64(3, 'UTC') - data: JSON - user_id: Nullable(String) @@ -756,7 +756,7 @@ You are helping users query their Stack Auth project's analytics data using Clic **Available Tables:** **events** - User activity events -- event_type: LowCardinality(String) - $token-refresh is the only valid event_type right now, it occurs whenever an access token is refreshed +- event_type: LowCardinality(String) - Event name. Stack-managed events use a \`$\` prefix (for example \`$token-refresh\`, \`$page-view\`, \`$click\`, \`$tab-in\`, \`$tab-out\`, \`$window-focus\`, \`$window-blur\`, \`$submit\`, \`$scroll-depth\`, \`$rage-click\`, \`$copy\`, \`$paste\`, \`$error\`), and projects may also send custom event names without a \`$\` prefix - event_at: DateTime64(3, 'UTC') - When the event occurred - data: JSON - Additional event data - user_id: Nullable(String) - Associated user ID diff --git a/apps/backend/src/lib/ai/tools/docs.ts b/apps/backend/src/lib/ai/tools/docs.ts index 61d305c0a8..3975e400a1 100644 --- a/apps/backend/src/lib/ai/tools/docs.ts +++ b/apps/backend/src/lib/ai/tools/docs.ts @@ -1,21 +1,131 @@ -import { createMCPClient } from "@ai-sdk/mcp"; -import { getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; +import { tool } from "ai"; +import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; +import { captureError } from "@stackframe/stack-shared/dist/utils/errors"; +import { z } from "zod"; + +type DocsToolHttpResult = { + content?: Array<{ type: string, text?: string }>, + isError?: boolean, +}; + +function getDocsToolsBaseUrl(): string { + const fromEnv = getEnvVariable("STACK_DOCS_INTERNAL_BASE_URL", ""); + if (fromEnv !== "") { + return fromEnv.replace(/\/$/, ""); + } + if (getNodeEnvironment() === "development") { + const portPrefix = getEnvVariable("NEXT_PUBLIC_STACK_PORT_PREFIX", "81"); + return `http://localhost:${portPrefix}04`; + } + return "https://mcp.stack-auth.com"; +} + +async function postDocsToolAction(action: Record): Promise { + const base = getDocsToolsBaseUrl(); + const secret = getEnvVariable("STACK_INTERNAL_DOCS_TOOLS_SECRET", ""); + const headers = new Headers({ "Content-Type": "application/json" }); + if (secret !== "") { + headers.set("x-stack-internal-docs-tools-secret", secret); + } + + const res = await fetch(`${base}/api/internal/docs-tools`, { + method: "POST", + headers, + body: JSON.stringify(action), + }); + + if (!res.ok) { + const errBody = await res.text(); + captureError("docs-tools-http-error", new Error(`Stack Auth docs tools error (${res.status}): ${errBody}`)); + return `Stack Auth docs tools error (${res.status}): ${errBody}`; + } + + const data = (await res.json()) as DocsToolHttpResult; + const text = data.content + ?.filter((c): c is { type: "text", text: string } => c.type === "text" && typeof c.text === "string") + .map((c) => c.text) + .join("\n") ?? ""; + + if (data.isError === true) { + return text || "Unknown docs tool error"; + } + + return text; +} /** - * Creates an MCP client connected to the Stack Auth documentation server. + * Documentation tools backed by the docs app's `/api/internal/docs-tools` endpoint. * - * In development: connects to local docs server at http://localhost:8104 - * In production: connects to production docs server at https://mcp.stack-auth.com + * The public MCP server at the same docs origin exposes only `ask_stack_auth`, which proxies to + * `/api/latest/ai/query/generate`; these tools avoid MCP recursion by calling the HTTP API directly. */ export async function createDocsTools() { - const mcpUrl = - getNodeEnvironment() === "development" - ? new URL("/api/internal/mcp", "http://localhost:8104") - : new URL("/api/internal/mcp", "https://mcp.stack-auth.com"); + return { + list_available_docs: tool({ + description: + "Use this tool to learn about what Stack Auth is, available documentation, and see if you can use it for what you're working on. It returns a list of all available Stack Auth Documentation pages.", + inputSchema: z.object({}), + execute: async () => { + return await postDocsToolAction({ action: "list_available_docs" }); + }, + }), - const stackAuthMcp = await createMCPClient({ - transport: { type: "http", url: mcpUrl.toString() }, - }); + search_docs: tool({ + description: + "Search through all Stack Auth documentation including API docs, guides, and examples. Returns ranked results with snippets and relevance scores.", + inputSchema: z.object({ + search_query: z.string().describe("The search query to find relevant documentation"), + result_limit: z.number().optional().describe("Maximum number of results to return (default: 50)"), + }), + execute: async ({ search_query, result_limit = 50 }) => { + return await postDocsToolAction({ + action: "search_docs", + search_query, + result_limit, + }); + }, + }), + + get_docs_by_id: tool({ + description: + "Use this tool to retrieve a specific Stack Auth Documentation page by its ID. It gives you the full content of the page so you can know exactly how to use specific Stack Auth APIs. Whenever using Stack Auth, you should always check the documentation first to have the most up-to-date information. When you write code using Stack Auth documentation you should reference the content you used in your comments.", + inputSchema: z.object({ + id: z.string(), + }), + execute: async ({ id }) => { + return await postDocsToolAction({ action: "get_docs_by_id", id }); + }, + }), + + get_stack_auth_setup_instructions: tool({ + description: + "Use this tool when the user wants to set up authentication in a new project. It provides step-by-step instructions for installing and configuring Stack Auth authentication.", + inputSchema: z.object({}), + execute: async () => { + return await postDocsToolAction({ action: "get_stack_auth_setup_instructions" }); + }, + }), + + search: tool({ + description: + "Search for Stack Auth documentation pages.\n\nUse this tool to find documentation pages that contain a specific keyword or phrase.", + inputSchema: z.object({ + query: z.string(), + }), + execute: async ({ query }) => { + return await postDocsToolAction({ action: "search", query }); + }, + }), - return await stackAuthMcp.tools(); + fetch: tool({ + description: + "Fetch a particular Stack Auth Documentation page by its ID.\n\nThis tool is identical to `get_docs_by_id`.", + inputSchema: z.object({ + id: z.string(), + }), + execute: async ({ id }) => { + return await postDocsToolAction({ action: "fetch", id }); + }, + }), + }; } diff --git a/apps/backend/src/lib/analytics-validation.ts b/apps/backend/src/lib/analytics-validation.ts new file mode 100644 index 0000000000..0fd659b144 --- /dev/null +++ b/apps/backend/src/lib/analytics-validation.ts @@ -0,0 +1,84 @@ +/** + * Shared validation constants and helpers for analytics events and spans. + * + * Custom names (event_type, span_type) must: + * - Not start with `$` (reserved for Stack-internal types) + * - Only contain letters, numbers, `.`, `_`, `:`, `-` + */ + +import { AUTO_CAPTURED_ANALYTICS_EVENT_TYPES } from "@stackframe/stack-shared/dist/interface/crud/analytics"; + +// Accepts standard UUIDs, and also 16-char / 32-char hex strings for OpenTelemetry IDs. +export const UUID_RE = /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{16}|[0-9a-f]{32})$/i; + +export const CUSTOM_ANALYTICS_NAME_RE = /^[A-Za-z0-9._:-]+$/; + +/** $-prefixed event types that browser clients may send. */ +const CLIENT_ANALYTICS_EVENT_TYPE_SET = new Set(AUTO_CAPTURED_ANALYTICS_EVENT_TYPES); + +/** $-prefixed event types that only server/admin auth may send. */ +const SERVER_ONLY_ANALYTICS_EVENT_TYPES = ["$request"] as const; + +const ALL_PUBLIC_ANALYTICS_EVENT_TYPES = [...AUTO_CAPTURED_ANALYTICS_EVENT_TYPES, ...SERVER_ONLY_ANALYTICS_EVENT_TYPES]; +const ALL_PUBLIC_ANALYTICS_EVENT_TYPE_SET = new Set(ALL_PUBLIC_ANALYTICS_EVENT_TYPES); + +export const PUBLIC_STACK_ANALYTICS_EVENT_TYPE_LIST = ALL_PUBLIC_ANALYTICS_EVENT_TYPES + .map((eventType) => `"${eventType}"`) + .join(", "); + +/** + * Validates a custom analytics name (event_type or span_type) that may be sent by clients. + * + * @param allowedSystemNames - Set of `$`-prefixed names that are allowed (e.g., auto-captured event types). + * For spans, this should be empty (no public $-prefixed span types). + */ +export function isValidCustomAnalyticsName( + name: string | undefined, + allowedSystemNames: Set = new Set(), +): boolean { + if (typeof name !== "string" || name.length === 0) { + return false; + } + if (allowedSystemNames.has(name)) { + return true; + } + return !name.startsWith("$") && CUSTOM_ANALYTICS_NAME_RE.test(name); +} + +/** + * Validates event_type for client auth (browser auto-captured + custom names). + */ +export function isValidClientAnalyticsEventType(eventType: string | undefined): boolean { + return isValidCustomAnalyticsName(eventType, CLIENT_ANALYTICS_EVENT_TYPE_SET); +} + +/** + * Validates event_type for server/admin auth (all public types + custom names). + */ +export function isValidPublicAnalyticsEventType(eventType: string | undefined): boolean { + return isValidCustomAnalyticsName(eventType, ALL_PUBLIC_ANALYTICS_EVENT_TYPE_SET); +} + +/** + * Pre-built validator for span_type. + * No public $-prefixed span types exist, so all $-prefixed names are rejected. + */ +export function isValidPublicAnalyticsSpanType(spanType: string | undefined): boolean { + return isValidCustomAnalyticsName(spanType); +} + +// Lone surrogates are valid in JS strings but rejected by ClickHouse's JSON parser. +// Replace them with U+FFFD before inserting. +// eslint-disable-next-line no-control-regex +const LONE_SURROGATE_RE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? [k, stripLoneSurrogates(v)]) + ); + } + return value; +} diff --git a/apps/backend/src/lib/events.test.ts b/apps/backend/src/lib/events.test.ts new file mode 100644 index 0000000000..7b4f5d8be1 --- /dev/null +++ b/apps/backend/src/lib/events.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const clickhouseInsertMock = vi.fn(); +const otelEmitMock = vi.fn(); +const activeSpanAddEventMock = vi.fn(); +const getActiveSpanMock = vi.fn(() => ({ + addEvent: activeSpanAddEventMock, +})); + +vi.mock("./clickhouse", () => ({ + getClickhouseAdminClient: () => ({ + insert: clickhouseInsertMock, + }), +})); + +vi.mock("@opentelemetry/api-logs", () => ({ + logs: { + getLogger: () => ({ + emit: otelEmitMock, + }), + }, + SeverityNumber: { + INFO: 9, + }, +})); + +vi.mock("@opentelemetry/api", () => ({ + trace: { + getTracer: () => ({ + startActiveSpan: async (_name: string, callback: (span: { end: () => void }) => Promise) => await callback({ end: () => {} }), + }), + getActiveSpan: getActiveSpanMock, + }, +})); + +describe("insertAnalyticsEvents", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("inserts events and fans out the normalized envelope to telemetry and logs", async () => { + const consoleInfoSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + const { insertAnalyticsEvents } = await import("./events"); + + await insertAnalyticsEvents([{ + event_type: "checkout.completed", + event_id: "00000000-0000-4000-8000-000000000001", + trace_id: null, + event_at: new Date("2026-03-23T12:00:00.000Z"), + parent_span_ids: [], + data: { + text: "broken \uD83C", + }, + project_id: "project-id", + branch_id: "main", + user_id: "user-id", + team_id: null, + refresh_token_id: null, + session_replay_id: null, + session_replay_segment_id: null, + from_server: false, + }]); + + expect(clickhouseInsertMock).toHaveBeenCalledTimes(1); + expect(clickhouseInsertMock.mock.calls[0][0]).toMatchObject({ + table: "analytics_internal.events", + values: [{ + event_type: "checkout.completed", + event_id: "00000000-0000-4000-8000-000000000001", + parent_span_ids: [], + data: { + text: "broken \uFFFD", + }, + }], + }); + + expect(otelEmitMock).toHaveBeenCalledTimes(1); + expect(otelEmitMock.mock.calls[0][0]).toMatchObject({ + body: expect.stringContaining("\"event_type\":\"checkout.completed\""), + }); + + expect(activeSpanAddEventMock).toHaveBeenCalledTimes(1); + expect(activeSpanAddEventMock).toHaveBeenCalledWith( + "stack.analytics.event", + expect.objectContaining({ + "stack.analytics.event_type": "checkout.completed", + }), + ); + + expect(consoleInfoSpy).toHaveBeenCalledTimes(1); + expect(consoleInfoSpy.mock.calls[0][0]).toContain("\"type\":\"stack.analytics.event\""); + expect(consoleInfoSpy.mock.calls[0][0]).toContain("\"event_type\":\"checkout.completed\""); + + consoleInfoSpy.mockRestore(); + }); +}); diff --git a/apps/backend/src/lib/events.tsx b/apps/backend/src/lib/events.tsx index 7fe9a1ae4f..a7ec9c56a6 100644 --- a/apps/backend/src/lib/events.tsx +++ b/apps/backend/src/lib/events.tsx @@ -1,3 +1,5 @@ +import { trace } from "@opentelemetry/api"; +import { SeverityNumber, logs } from "@opentelemetry/api-logs"; import withPostHog from "@/analytics"; import { globalPrismaClient } from "@/prisma-client"; import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel"; @@ -54,6 +56,108 @@ function toClickhouseEndUserIpInfo(ipInfo: EndUserIpInfo | null): ClickhouseEndU }; } +const analyticsLogger = logs.getLogger("stack-backend"); + +export type AnalyticsEventInsertRow = { + event_type: string, + event_id: string, + trace_id: string | null, + event_at: Date, + parent_span_ids: string[], + data: Record, + project_id: string, + branch_id: string, + user_id: string | null, + team_id: string | null, + refresh_token_id: string | null, + session_replay_id: string | null, + session_replay_segment_id: string | null, + from_server: boolean, +}; + +type AnalyticsEventEnvelope = Omit & { + event_at: string, +}; + +import { stripLoneSurrogates } from "@/lib/analytics-validation"; + +function sanitizeAnalyticsEventData(data: Record): Record { + const sanitized = stripLoneSurrogates(data); + if (sanitized === null || typeof sanitized !== "object" || Array.isArray(sanitized)) { + throw new StackAssertionError("Analytics event data must be a JSON object", { data }); + } + return sanitized as Record; +} + +function toAnalyticsEventEnvelope(row: AnalyticsEventInsertRow): AnalyticsEventEnvelope { + return { + ...row, + event_at: row.event_at.toISOString(), + }; +} + +function getAnalyticsEventTelemetryAttributes(event: AnalyticsEventEnvelope) { + return filterUndefined({ + "stack.analytics.event_type": event.event_type, + "stack.analytics.event_id": event.event_id, + "stack.analytics.trace_id": event.trace_id ?? undefined, + "stack.analytics.event_at": event.event_at, + "stack.analytics.parent_span_ids": event.parent_span_ids.length > 0 ? event.parent_span_ids.join(",") : undefined, + "stack.analytics.project_id": event.project_id, + "stack.analytics.branch_id": event.branch_id, + "stack.analytics.user_id": event.user_id ?? undefined, + "stack.analytics.team_id": event.team_id ?? undefined, + "stack.analytics.refresh_token_id": event.refresh_token_id ?? undefined, + "stack.analytics.session_replay_id": event.session_replay_id ?? undefined, + "stack.analytics.session_replay_segment_id": event.session_replay_segment_id ?? undefined, + "stack.analytics.data_json": JSON.stringify(event.data), + }); +} + +function exportAnalyticsEvent(row: AnalyticsEventInsertRow) { + const event = toAnalyticsEventEnvelope(row); + const attributes = getAnalyticsEventTelemetryAttributes(event); + + analyticsLogger.emit({ + severityNumber: SeverityNumber.INFO, + severityText: "INFO", + body: JSON.stringify(event), + attributes, + }); + + trace.getActiveSpan()?.addEvent("stack.analytics.event", attributes); + + console.info(JSON.stringify({ + type: "stack.analytics.event", + ...event, + })); +} + +export async function insertAnalyticsEvents(rows: AnalyticsEventInsertRow[]): Promise { + if (rows.length === 0) { + return; + } + + const sanitizedRows = rows.map((row) => ({ + ...row, + data: sanitizeAnalyticsEventData(row.data), + })); + + await getClickhouseAdminClient().insert({ + table: "analytics_internal.events", + values: sanitizedRows, + format: "JSONEachRow", + clickhouse_settings: { + date_time_input_format: "best_effort", + async_insert: 1, + }, + }); + + for (const row of sanitizedRows) { + exportAnalyticsEvent(row); + } +} + /** * Extracts the end user IP info from the current request. * Must be called before any async operations as it uses dynamic APIs. @@ -177,6 +281,7 @@ export const SystemEventTypes = stripEventTypeSuffixFromKeys({ SignUpRuleTriggerEventType, } as const); const systemEventTypesById = new Map(Object.values(SystemEventTypes).map(eventType => [eventType.id, eventType])); +const clickhouseSystemEventTypeIds = new Set(["$token-refresh", "$sign-up-rule-trigger"]); function stripEventTypeSuffixFromKeys>(t: T): { [K in keyof T as K extends `${infer Key}EventType` ? Key : never]: T[K] } { return Object.fromEntries(Object.entries(t).map(([key, value]) => [key.replace(/EventType$/, ""), value])) as any; @@ -188,6 +293,67 @@ type DataOf = & yup.InferType & DataOfMany; +function getAnalyticsDataForLoggedEvent(eventTypeId: string, dataRecord: Record | null | undefined): Record { + if (eventTypeId === "$token-refresh") { + const refreshTokenId = + typeof dataRecord === "object" && dataRecord && typeof dataRecord.refreshTokenId === "string" + ? dataRecord.refreshTokenId + : throwErr(new StackAssertionError("refreshTokenId is required for $token-refresh ClickHouse event", { dataRecord })); + const isAnonymous = + typeof dataRecord === "object" && dataRecord && typeof dataRecord.isAnonymous === "boolean" + ? dataRecord.isAnonymous + : throwErr(new StackAssertionError("isAnonymous is required for $token-refresh ClickHouse event", { dataRecord })); + const ipInfo = + typeof dataRecord === "object" && dataRecord + ? (dataRecord.ipInfo as EndUserIpInfo | null | undefined) + : undefined; + return { + refresh_token_id: refreshTokenId, + is_anonymous: isAnonymous, + ip_info: toClickhouseEndUserIpInfo(ipInfo ?? null), + }; + } + + if (eventTypeId === "$sign-up-rule-trigger") { + const ruleId = + typeof dataRecord === "object" && dataRecord && typeof dataRecord.ruleId === "string" + ? dataRecord.ruleId + : throwErr(new StackAssertionError("ruleId is required for $sign-up-rule-trigger ClickHouse event", { dataRecord })); + const action = + typeof dataRecord === "object" && dataRecord && typeof dataRecord.action === "string" + ? dataRecord.action + : throwErr(new StackAssertionError("action is required for $sign-up-rule-trigger ClickHouse event", { dataRecord })); + const email = + typeof dataRecord === "object" && dataRecord + ? (dataRecord.email as string | null | undefined) ?? null + : null; + const authMethod = + typeof dataRecord === "object" && dataRecord + ? (dataRecord.authMethod as string | null | undefined) ?? null + : null; + const oauthProvider = + typeof dataRecord === "object" && dataRecord + ? (dataRecord.oauthProvider as string | null | undefined) ?? null + : null; + return { + rule_id: ruleId, + action, + email, + auth_method: authMethod, + oauth_provider: oauthProvider, + }; + } + + if (dataRecord === null || dataRecord === undefined || Array.isArray(dataRecord)) { + throw new StackAssertionError( + `Analytics event data for ${eventTypeId} must be a JSON object`, + { dataRecord }, + ); + } + + return dataRecord; +} + /** * Do not wrap this function in waitUntil or runAsynchronously as it may use dynamic APIs */ @@ -211,8 +377,6 @@ export async function logEvent( if (!systemEventTypesById.has(eventType.id as any)) { throw new StackAssertionError(`Invalid system event type: ${eventType.id}`, { eventType }); } - } else { - throw new StackAssertionError(`Non-system event types are not supported yet`, { eventType }); } } @@ -267,7 +431,9 @@ export async function logEvent( // log event in DB await globalPrismaClient.event.create({ data: { - systemEventTypeIds: eventTypesArray.map(eventType => eventType.id), + systemEventTypeIds: eventTypesArray + .filter((eventType) => eventType.id.startsWith("$")) + .map((eventType) => eventType.id), data: data as any, isEndUserIpInfoGuessTrusted: !endUserInfo?.maybeSpoofed, endUserIpInfoGuess: endUserInfoInner ? { @@ -287,94 +453,44 @@ export async function logEvent( }, }); - // Log specific events to ClickHouse - const clickhouseEventTypes = ['$token-refresh', '$sign-up-rule-trigger']; - const matchingEventType = eventTypesArray.find(e => clickhouseEventTypes.includes(e.id)); - if (matchingEventType) { - let clickhouseEventData: Record; - if (matchingEventType.id === "$token-refresh") { - const refreshTokenId = - typeof dataRecord === "object" && dataRecord && typeof dataRecord.refreshTokenId === "string" - ? dataRecord.refreshTokenId - : throwErr(new StackAssertionError("refreshTokenId is required for $token-refresh ClickHouse event", { dataRecord })); - const isAnonymous = - typeof dataRecord === "object" && dataRecord && typeof dataRecord.isAnonymous === "boolean" - ? dataRecord.isAnonymous - : throwErr(new StackAssertionError("isAnonymous is required for $token-refresh ClickHouse event", { dataRecord })); - const ipInfo = - typeof dataRecord === "object" && dataRecord - ? (dataRecord.ipInfo as EndUserIpInfo | null | undefined) - : undefined; - clickhouseEventData = { - refresh_token_id: refreshTokenId, - is_anonymous: isAnonymous, - ip_info: toClickhouseEndUserIpInfo(ipInfo ?? null), - }; - } else if (matchingEventType.id === "$sign-up-rule-trigger") { - const ruleId = - typeof dataRecord === "object" && dataRecord && typeof dataRecord.ruleId === "string" - ? dataRecord.ruleId - : throwErr(new StackAssertionError("ruleId is required for $sign-up-rule-trigger ClickHouse event", { dataRecord })); - const action = - typeof dataRecord === "object" && dataRecord && typeof dataRecord.action === "string" - ? dataRecord.action - : throwErr(new StackAssertionError("action is required for $sign-up-rule-trigger ClickHouse event", { dataRecord })); - const email = - typeof dataRecord === "object" && dataRecord - ? (dataRecord.email as string | null | undefined) ?? null - : null; - const authMethod = - typeof dataRecord === "object" && dataRecord - ? (dataRecord.authMethod as string | null | undefined) ?? null - : null; - const oauthProvider = - typeof dataRecord === "object" && dataRecord - ? (dataRecord.oauthProvider as string | null | undefined) ?? null - : null; - clickhouseEventData = { - rule_id: ruleId, - action, - email, - auth_method: authMethod, - oauth_provider: oauthProvider, - }; - } else { - throw new StackAssertionError(`Unhandled ClickHouse event type: ${matchingEventType.id}`, { matchingEventType }); - } + const analyticsRows = eventTypes + .filter((eventType) => !eventType.id.startsWith("$") || clickhouseSystemEventTypeIds.has(eventType.id)) + .map((eventType) => { + if (!projectId) { + throw new StackAssertionError( + `projectId is required for ClickHouse event insertion (${eventType.id})`, + { eventType, dataRecord }, + ); + } - if (!projectId) { - throw new StackAssertionError( - `projectId is required for ClickHouse event insertion (${matchingEventType.id})`, - { matchingEventType, dataRecord } - ); - } - const clickhouseClient = getClickhouseAdminClient(); - // Resolve refresh_token_id: prefer explicit option, fall back to data for $token-refresh events - const resolvedRefreshTokenId = options.refreshTokenId - ?? (matchingEventType.id === "$token-refresh" && typeof (clickhouseEventData as any).refresh_token_id === "string" - ? (clickhouseEventData as any).refresh_token_id as string - : null); - - await clickhouseClient.insert({ - table: "analytics_internal.events", - values: [{ - event_type: matchingEventType.id, + const analyticsData = getAnalyticsDataForLoggedEvent(eventType.id, dataRecord); + const resolvedRefreshTokenId = options.refreshTokenId + ?? (eventType.id === "$token-refresh" && typeof analyticsData.refresh_token_id === "string" + ? analyticsData.refresh_token_id + : null); + + const sessionReplayId = options.sessionReplayId ?? null; + const sessionReplaySegmentId = options.sessionReplaySegmentId ?? null; + return { + event_type: eventType.id, + event_id: generateUuid(), + trace_id: generateUuid(), event_at: timeRange.end, - data: clickhouseEventData, + parent_span_ids: sessionReplaySegmentId ? [sessionReplaySegmentId] : [], + data: analyticsData, project_id: projectId, branch_id: branchId, user_id: userId || null, team_id: null, refresh_token_id: resolvedRefreshTokenId ?? null, - session_replay_id: options.sessionReplayId ?? null, + session_replay_id: sessionReplayId, session_replay_segment_id: options.sessionReplaySegmentId ?? null, - }], - format: "JSONEachRow", - clickhouse_settings: { - date_time_input_format: "best_effort", - async_insert: 1, - }, + from_server: true, + } satisfies AnalyticsEventInsertRow; }); + + if (analyticsRows.length > 0) { + await insertAnalyticsEvents(analyticsRows); } // log event in PostHog diff --git a/apps/backend/src/lib/spans.tsx b/apps/backend/src/lib/spans.tsx new file mode 100644 index 0000000000..1553d127e1 --- /dev/null +++ b/apps/backend/src/lib/spans.tsx @@ -0,0 +1,39 @@ +import { stripLoneSurrogates } from "@/lib/analytics-validation"; +import { getClickhouseAdminClient } from "./clickhouse"; + +export type SpanInsertRow = { + span_type: string, + span_id: string, + trace_id: string | null, + started_at: Date, + ended_at: Date | null, + parent_ids: string[], + data: Record, + project_id: string, + branch_id: string, + user_id: string | null, + team_id: string | null, + refresh_token_id: string | null, + session_replay_id: string | null, + session_replay_segment_id: string | null, + from_server: boolean, +}; + +export async function insertSpans(rows: SpanInsertRow[]): Promise { + if (rows.length === 0) return; + + const sanitizedRows = rows.map((row) => ({ + ...row, + data: stripLoneSurrogates(row.data) as Record, + })); + + await getClickhouseAdminClient().insert({ + table: "analytics_internal.spans", + values: sanitizedRows, + format: "JSONEachRow", + clickhouse_settings: { + date_time_input_format: "best_effort", + async_insert: 1, + }, + }); +} diff --git a/apps/dashboard/public/cloudflare-workers-logo.svg b/apps/dashboard/public/cloudflare-workers-logo.svg new file mode 100644 index 0000000000..597c28cd83 --- /dev/null +++ b/apps/dashboard/public/cloudflare-workers-logo.svg @@ -0,0 +1 @@ +Cloudflare \ No newline at end of file diff --git a/apps/dashboard/public/express-logo.svg b/apps/dashboard/public/express-logo.svg new file mode 100644 index 0000000000..96c2be8f0e --- /dev/null +++ b/apps/dashboard/public/express-logo.svg @@ -0,0 +1 @@ +Express \ No newline at end of file diff --git a/apps/dashboard/public/hono-logo.svg b/apps/dashboard/public/hono-logo.svg new file mode 100644 index 0000000000..541aeabad4 --- /dev/null +++ b/apps/dashboard/public/hono-logo.svg @@ -0,0 +1 @@ +Hono \ No newline at end of file diff --git a/apps/dashboard/public/nestjs-logo.svg b/apps/dashboard/public/nestjs-logo.svg new file mode 100644 index 0000000000..2809dc79a8 --- /dev/null +++ b/apps/dashboard/public/nestjs-logo.svg @@ -0,0 +1 @@ +NestJS \ No newline at end of file diff --git a/apps/dashboard/public/next-logo.svg b/apps/dashboard/public/next-logo.svg index 50ccbbd18e..42e365b4dc 100644 --- a/apps/dashboard/public/next-logo.svg +++ b/apps/dashboard/public/next-logo.svg @@ -1,8 +1 @@ - - - - - - - - \ No newline at end of file +Next.js \ No newline at end of file diff --git a/apps/dashboard/public/nuxt-logo.svg b/apps/dashboard/public/nuxt-logo.svg new file mode 100644 index 0000000000..30220484de --- /dev/null +++ b/apps/dashboard/public/nuxt-logo.svg @@ -0,0 +1 @@ +Nuxt \ No newline at end of file diff --git a/apps/dashboard/public/react-router-logo.svg b/apps/dashboard/public/react-router-logo.svg new file mode 100644 index 0000000000..205826333f --- /dev/null +++ b/apps/dashboard/public/react-router-logo.svg @@ -0,0 +1 @@ +React Router \ No newline at end of file diff --git a/apps/dashboard/public/sveltekit-logo.svg b/apps/dashboard/public/sveltekit-logo.svg new file mode 100644 index 0000000000..e6ca975fec --- /dev/null +++ b/apps/dashboard/public/sveltekit-logo.svg @@ -0,0 +1 @@ +Svelte \ No newline at end of file diff --git a/apps/dashboard/public/tanstack-start-logo.svg b/apps/dashboard/public/tanstack-start-logo.svg new file mode 100644 index 0000000000..6bd0205c36 --- /dev/null +++ b/apps/dashboard/public/tanstack-start-logo.svg @@ -0,0 +1 @@ +TanStack \ No newline at end of file diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/setup-frameworks.test.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/setup-frameworks.test.ts new file mode 100644 index 0000000000..40eb32c677 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/setup-frameworks.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { getSetupFramework, setupFrameworkGroups } from "./setup-frameworks"; + +describe("setup-frameworks", () => { + it("maps first-wave frameworks to the expected package families", () => { + expect(getSetupFramework("nextjs").packageName).toBe("@stackframe/stack"); + expect(getSetupFramework("react-router").packageName).toBe("@stackframe/react"); + expect(getSetupFramework("tanstack-start").packageName).toBe("@stackframe/react"); + expect(getSetupFramework("nuxt").packageName).toBe("@stackframe/js"); + expect(getSetupFramework("sveltekit").packageName).toBe("@stackframe/js"); + expect(getSetupFramework("nestjs").packageName).toBe("@stackframe/js"); + expect(getSetupFramework("express").packageName).toBe("@stackframe/js"); + expect(getSetupFramework("hono").packageName).toBe("@stackframe/js"); + expect(getSetupFramework("cloudflare-workers").packageName).toBe("@stackframe/js"); + }); + + it("uses framework env snippets only where the convention is stable", () => { + expect(getSetupFramework("nextjs").envPreset).toBe("nextjs"); + expect(getSetupFramework("react-router").envPreset).toBe("vite"); + expect(getSetupFramework("tanstack-start").envPreset).toBe("vite"); + expect(getSetupFramework("nuxt").envPreset).toBe("nuxt"); + expect(getSetupFramework("sveltekit").envPreset).toBe("sveltekit"); + expect(getSetupFramework("nestjs").envPreset).toBeNull(); + expect(getSetupFramework("express").envPreset).toBeNull(); + expect(getSetupFramework("hono").envPreset).toBeNull(); + expect(getSetupFramework("cloudflare-workers").envPreset).toBeNull(); + }); + + it("keeps the grouped selector aligned with the support tiers", () => { + expect(setupFrameworkGroups).toMatchInlineSnapshot(` + [ + { + "frameworkIds": [ + "nextjs", + "react-router", + "tanstack-start", + ], + "id": "react-apps", + "name": "React apps", + }, + { + "frameworkIds": [ + "nuxt", + "sveltekit", + ], + "id": "full-stack-js", + "name": "Full-stack JS", + }, + { + "frameworkIds": [ + "nestjs", + "express", + "hono", + "cloudflare-workers", + ], + "id": "server-edge", + "name": "Server / edge", + }, + ] + `); + }); +}); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/setup-frameworks.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/setup-frameworks.ts new file mode 100644 index 0000000000..61e6420dfe --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/setup-frameworks.ts @@ -0,0 +1,156 @@ +import type { EnvSnippetPreset } from "@/components/env-keys"; + +export type SetupFrameworkId = + | "nextjs" + | "react-router" + | "tanstack-start" + | "nuxt" + | "sveltekit" + | "nestjs" + | "express" + | "hono" + | "cloudflare-workers"; + +export type SetupFrameworkGroupId = "react-apps" | "full-stack-js" | "server-edge"; + +export type SetupFramework = { + id: SetupFrameworkId, + name: string, + packageName: "@stackframe/stack" | "@stackframe/react" | "@stackframe/js", + groupId: SetupFrameworkGroupId, + imgSrc: string, + reverseIfDark: boolean, + envPreset: EnvSnippetPreset | null, + usesStackHandler: boolean, + usesProviders: boolean, +}; + +export type SetupFrameworkGroup = { + id: SetupFrameworkGroupId, + name: string, + frameworkIds: SetupFrameworkId[], +}; + +export const setupFrameworks: Record = { + nextjs: { + id: "nextjs", + name: "Next.js", + packageName: "@stackframe/stack", + groupId: "react-apps", + imgSrc: "/next-logo.svg", + reverseIfDark: true, + envPreset: "nextjs", + usesStackHandler: true, + usesProviders: true, + }, + "react-router": { + id: "react-router", + name: "React Router", + packageName: "@stackframe/react", + groupId: "react-apps", + imgSrc: "/react-router-logo.svg", + reverseIfDark: true, + envPreset: "vite", + usesStackHandler: true, + usesProviders: true, + }, + "tanstack-start": { + id: "tanstack-start", + name: "TanStack Start", + packageName: "@stackframe/react", + groupId: "react-apps", + imgSrc: "/tanstack-start-logo.svg", + reverseIfDark: true, + envPreset: "vite", + usesStackHandler: true, + usesProviders: true, + }, + nuxt: { + id: "nuxt", + name: "Nuxt", + packageName: "@stackframe/js", + groupId: "full-stack-js", + imgSrc: "/nuxt-logo.svg", + reverseIfDark: true, + envPreset: "nuxt", + usesStackHandler: false, + usesProviders: false, + }, + sveltekit: { + id: "sveltekit", + name: "SvelteKit", + packageName: "@stackframe/js", + groupId: "full-stack-js", + imgSrc: "/sveltekit-logo.svg", + reverseIfDark: true, + envPreset: "sveltekit", + usesStackHandler: false, + usesProviders: false, + }, + nestjs: { + id: "nestjs", + name: "NestJS", + packageName: "@stackframe/js", + groupId: "server-edge", + imgSrc: "/nestjs-logo.svg", + reverseIfDark: true, + envPreset: null, + usesStackHandler: false, + usesProviders: false, + }, + express: { + id: "express", + name: "Express", + packageName: "@stackframe/js", + groupId: "server-edge", + imgSrc: "/express-logo.svg", + reverseIfDark: true, + envPreset: null, + usesStackHandler: false, + usesProviders: false, + }, + hono: { + id: "hono", + name: "Hono", + packageName: "@stackframe/js", + groupId: "server-edge", + imgSrc: "/hono-logo.svg", + reverseIfDark: true, + envPreset: null, + usesStackHandler: false, + usesProviders: false, + }, + "cloudflare-workers": { + id: "cloudflare-workers", + name: "Cloudflare Workers", + packageName: "@stackframe/js", + groupId: "server-edge", + imgSrc: "/cloudflare-workers-logo.svg", + reverseIfDark: true, + envPreset: null, + usesStackHandler: false, + usesProviders: false, + }, +}; + +export const setupFrameworkGroups: SetupFrameworkGroup[] = [ + { + id: "react-apps", + name: "React apps", + frameworkIds: ["nextjs", "react-router", "tanstack-start"], + }, + { + id: "full-stack-js", + name: "Full-stack JS", + frameworkIds: ["nuxt", "sveltekit"], + }, + { + id: "server-edge", + name: "Server / edge", + frameworkIds: ["nestjs", "express", "hono", "cloudflare-workers"], + }, +]; + +export function getSetupFramework(id: SetupFrameworkId): SetupFramework { + return setupFrameworks[id]; +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/setup-page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/setup-page.tsx index af81b406ce..f5737acb2a 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/setup-page.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/setup-page.tsx @@ -1,7 +1,7 @@ 'use client'; import { CodeBlock } from '@/components/code-block'; -import { APIEnvKeys, NextJsEnvKeys } from '@/components/env-keys'; +import { APIEnvKeys, FrameworkEnvKeys, type EnvSnippetPreset } from '@/components/env-keys'; import { InlineCode } from '@/components/inline-code'; import { StyledLink } from '@/components/link'; import { Tabs, TabsContent, TabsList, TabsTrigger, Typography, cn } from "@/components/ui"; @@ -15,6 +15,7 @@ import Image from 'next/image'; import { Suspense, useRef, useState } from "react"; import type { GlobeMethods } from 'react-globe.gl'; import { PageLayout } from "../page-layout"; +import { getSetupFramework, setupFrameworkGroups, type SetupFrameworkId } from './setup-frameworks'; import { useAdminApp } from '../use-admin-app'; import { globeImages } from './globe'; import styles from './setup-page.module.css'; @@ -27,15 +28,14 @@ const nameClasses = "text-green-600 dark:text-green-500"; export default function SetupPage(props: { toMetrics: () => void }) { const adminApp = useAdminApp(); - const [selectedFramework, setSelectedFramework] = useState<'nextjs' | 'react' | 'javascript' | 'python'>('nextjs'); + const [selectedFramework, setSelectedFramework] = useState('nextjs'); const [keys, setKeys] = useState<{ projectId: string, publishableClientKey?: string, secretServerKey: string } | null>(null); const projectConfig = adminApp.useProject().useConfig(); const requirePublishableClientKey = projectConfig.project.requirePublishableClientKey; const publishableClientKeyValue = keys?.publishableClientKey ?? "..."; + const framework = getSetupFramework(selectedFramework); const optionalPublishableClientKeyProp = (indent: string) => requirePublishableClientKey ? `\n${indent}publishableClientKey: "${publishableClientKeyValue}",` : ""; - const optionalPublishableClientKeyHeader = (indent: string) => - requirePublishableClientKey ? `\n${indent}'x-stack-publishable-client-key': "${publishableClientKeyValue}",` : ""; const onGenerateKeys = async () => { const newKey = await adminApp.createInternalApiKey({ @@ -53,375 +53,277 @@ export default function SetupPage(props: { toMetrics: () => void }) { }); }; - const nextJsSteps = [ - { - step: 2, - title: "Install Stack Auth", - content: <> - - In a new or existing Next.js project, install Stack Auth as a dependency into your project: - - - pnpx @stackframe/stack-cli@latest init - - } - title="Terminal" - icon="terminal" - /> - - }, - { - step: 3, - title: "Create Keys", - content: <> - - Put these keys in the .env.local file. - - - - }, - { - step: 4, - title: "Done", - content: <> - - If you start your Next.js app with npm run dev and navigate to http://localhost:3000/handler/signup, you will see the sign-up page. - - - }, - ]; - - const reactSteps = [ - { - step: 2, - title: "Install Stack Auth", - content: <> - - In a new or existing React project, install Stack Auth's dependencies: - - - npm install @stackframe/react - - } - title="Terminal" - icon="terminal" - /> - - }, - { - step: 3, - title: "Create Keys", - content: - }, - { - step: 4, - title: "Create stack/client.ts file", - content: <> - - Create a new file called stack/client.ts and add the following code. Here we use react-router-dom as an example. - - - - }, - { - step: 5, - title: "Update App.tsx", - content: <> - - Update your App.tsx file to wrap the entire app with a StackProvider and StackTheme and add a StackHandler component to handle the authentication flow. - - - ); - } + const stackClientSnippet = deindent` + import { StackClientApp } from "${framework.packageName}"; + ${selectedFramework === "react-router" ? 'import { useNavigate } from "react-router-dom";' : ""} + ${selectedFramework === "tanstack-start" ? 'import { useNavigate } from "@tanstack/react-router";' : ""} + + export const stackClientApp = new StackClientApp({ + ${framework.envPreset === "nextjs" ? "// Environment variables are automatically read" : `projectId: "${keys?.projectId ?? "..."}",${optionalPublishableClientKeyProp(" ")}`} + tokenStore: "${framework.packageName === "@stackframe/stack" ? "nextjs-cookie" : "cookie"}",${selectedFramework === "react-router" || selectedFramework === "tanstack-start" ? ` + redirectMethod: { + useNavigate, + },` : ""} + }); + `; - export default function App() { - return ( - - - - - - } /> - hello world} /> - - - - - - ); - } - `} - title="App.tsx" - icon="code" - /> - - }, - { - step: 6, - title: "Done", - content: <> - - If you start your React app with npm run dev and navigate to http://localhost:5173/handler/signup, you will see the sign-up page. - - - } - ]; - - const javascriptSteps = [ - { - step: 2, - title: "Install Stack Auth", - content: <> - - Install Stack Auth using npm: - - - npm install @stackframe/js - - } - title="Terminal" - icon="terminal" - /> - - }, - { - step: 3, - title: "Create Keys", - content: - }, - { - step: 4, - title: "Initialize the app", - content: <> - - Create a new file for your Stack app initialization: - - + const stackServerSnippet = deindent` + import { StackServerApp } from "${framework.packageName}"; + + export const stackServerApp = new StackServerApp({ + projectId: "${keys?.projectId ?? "..."}",${optionalPublishableClientKeyProp(" ")} + secretServerKey: "${keys?.secretServerKey ?? "..."}", + tokenStore: "memory", + }); + `; + + const installCommand = selectedFramework === "nextjs" + ? "npx @stackframe/stack-cli@latest init" + : `npm install ${framework.packageName}`; + + const installStep = { + step: 2, + title: "Install Stack Auth", + content: <> + + {selectedFramework === "nextjs" + ? "In a new or existing Next.js project, install Stack Auth using the initializer." + : `Install ${framework.packageName} for ${framework.name}.`} + + + {selectedFramework === "nextjs" ? "pnpx" : "npm install"}{" "} + {selectedFramework === "nextjs" ? "@stackframe/stack-cli@latest init" : framework.packageName} + + } + title="Terminal" + icon="terminal" + /> + , + }; + + const keysStep = { + step: 3, + title: "Create Keys", + content: <> + + {framework.envPreset + ? "Copy these into the framework-specific env format or configuration file." + : "Copy these raw keys into your runtime bindings, secrets manager, or server env configuration."} + + + , + }; + + const configStep = { + step: 4, + title: "Create Stack app files", + content: <> + {(selectedFramework === "nextjs" || selectedFramework === "react-router" || selectedFramework === "tanstack-start" || selectedFramework === "nuxt" || selectedFramework === "sveltekit") ? ( + - Server Client + Server - - - - + - - - }, - { - step: 5, - title: "Example usage", - content: <> - - - Server - Client - - + + + ) : ( + + )} + , + }; - const user = await stackServerApp.getUser("user_id"); + const integrationStep = { + step: 5, + title: framework.usesStackHandler ? "Integrate Stack into your app" : "Use Stack in your routes and handlers", + content: <> + Please sign in; + + return ( + + ); + } + ` : selectedFramework === "react-router" ? deindent` + import { StackHandler, StackProvider, StackTheme, useAnalytics } from "@stackframe/react"; + import { Suspense } from "react"; + import { BrowserRouter, Route, Routes, useLocation } from "react-router-dom"; + import { stackClientApp } from "./stack/client"; + + function HandlerRoutes() { + const location = useLocation(); + return ; + } - await user.update({ - displayName: "New Display Name", - }); + function Dashboard() { + const { track } = useAnalytics(); + return ; + } - const team = await stackServerApp.createTeam({ - name: "New Team", - }); + export default function App() { + return ( + + + + + + } /> + } /> + + + + + + ); + } + ` : selectedFramework === "tanstack-start" ? deindent` + import { StackHandler, StackProvider, StackTheme, useAnalytics } from "@stackframe/react"; + import { createFileRoute, useRouterState } from "@tanstack/react-router"; + import { stackClientApp } from "../stack/client"; + + export const Route = createFileRoute("/handler/$")({ + component: HandlerRoute, + }); + + function HandlerRoute() { + const pathname = useRouterState({ select: (state) => state.location.pathname }); + return ; + } - await team.addUser(user.id); - `} - title="Example server usage" - icon="code" - /> - - - - - - - } - ]; - - const pythonSteps = [ - { - step: 2, - title: "Install requests", - content: <> - - Install the requests library to make HTTP requests to the Stack Auth API: - - - pip install requests - + export function Dashboard() { + const { track } = useAnalytics(); + return ; + } + ` : selectedFramework === "nuxt" ? deindent` + import { tokenStoreFromHeaders } from "@stackframe/js"; + import { stackServerApp } from "~/stack/server"; + + export default defineEventHandler(async (event) => { + const tokenStore = tokenStoreFromHeaders(event.node.req.headers); + const user = await stackServerApp.getUser({ tokenStore }); + await stackServerApp.track("dashboard.viewed", { framework: "nuxt" }, { tokenStore }); + return { userId: user?.id ?? null }; + }); + ` : selectedFramework === "sveltekit" ? deindent` + import { stackServerApp } from "$lib/stack/server"; + + export async function load({ request }) { + const user = await stackServerApp.getUser({ tokenStore: request }); + await stackServerApp.track("dashboard.viewed", { framework: "sveltekit" }, { tokenStore: request }); + return { userId: user?.id ?? null }; } - title="Terminal" - icon="terminal" - /> - - }, - { - step: 3, - title: "Create Keys", - content: - }, - { - step: 4, - title: "Create helper function", - content: <> - - Create a helper function to make requests to the Stack Auth API: - - = 400: - raise Exception(f"Stack Auth API request failed with {res.status_code}: {res.text}") - return res.json() - `} - title="stack_auth.py" - icon="code" - /> - - }, - { - step: 5, - title: "Make requests", - content: <> - - You can now make requests to the Stack Auth API: - - - - } - ]; + ` : selectedFramework === "nestjs" ? deindent` + import { Controller, Get, Req } from "@nestjs/common"; + import { tokenStoreFromHeaders } from "@stackframe/js"; + import { stackServerApp } from "./stack/server"; + + @Controller("profile") + export class ProfileController { + @Get() + async read(@Req() req: { headers: Record }) { + const tokenStore = tokenStoreFromHeaders(req.headers); + const user = await stackServerApp.getUser({ tokenStore }); + await stackServerApp.track("dashboard.viewed", { framework: "nestjs" }, { tokenStore }); + return { userId: user?.id ?? null }; + } + } + ` : selectedFramework === "express" ? deindent` + import express from "express"; + import { tokenStoreFromHeaders } from "@stackframe/js"; + import { stackServerApp } from "./stack/server"; + + const app = express(); + app.get("/profile", async (req, res) => { + const tokenStore = tokenStoreFromHeaders(req.headers); + const user = await stackServerApp.getUser({ tokenStore }); + await stackServerApp.track("dashboard.viewed", { framework: "express" }, { tokenStore }); + res.json({ userId: user?.id ?? null }); + }); + ` : selectedFramework === "hono" ? deindent` + import { Hono } from "hono"; + import { stackServerApp } from "./stack/server"; + + const app = new Hono(); + app.get("/profile", async (c) => { + const user = await stackServerApp.getUser({ tokenStore: c.req.raw }); + await stackServerApp.track("dashboard.viewed", { framework: "hono" }, { tokenStore: c.req.raw }); + return c.json({ userId: user?.id ?? null }); + }); + ` : deindent` + import { createStackServerApp } from "./stack/server"; + + export default { + async fetch(request: Request, env: Env) { + const stackServerApp = createStackServerApp(env); + const user = await stackServerApp.getUser({ tokenStore: request }); + await stackServerApp.track("dashboard.viewed", { framework: "cloudflare-workers" }, { tokenStore: request }); + return Response.json({ userId: user?.id ?? null }); + }, + }; + `} + title={framework.usesStackHandler ? "App integration" : "Route / handler usage"} + icon="code" + /> + , + }; + + const doneStep = { + step: 6, + title: "Done", + content: ( + + {selectedFramework === "nextjs" ? ( + <>If you start your Next.js app and navigate to http://localhost:3000/handler/signup, you should see the sign-up page. + ) : framework.usesStackHandler ? ( + <>Start your app and open the handler route you mounted. Then trigger a page render or button click that calls useAnalytics() to verify the client-side flow. + ) : ( + <>Start your server/runtime and verify one request can call stackServerApp.getUser() and stackServerApp.track() using the selected framework pattern. + )} + + ), + }; return ( @@ -468,54 +370,48 @@ export default function SetupPage(props: { toMetrics: () => void }) { step: 1, title: "Select your framework", content:
-
- {([{ - id: 'nextjs', - name: 'Next.js', - reverseIfDark: true, - imgSrc: '/next-logo.svg', - }, { - id: 'react', - name: 'React', - reverseIfDark: false, - imgSrc: '/react-logo.svg', - }, { - id: 'javascript', - name: 'JavaScript', - reverseIfDark: false, - imgSrc: '/javascript-logo.svg', - }, { - id: 'python', - name: 'Python', - reverseIfDark: false, - imgSrc: '/python-logo.svg', - }] as const).map(({ name, imgSrc: src, reverseIfDark, id }) => ( - setSelectedFramework(id)} - > - {name} - {name} - +
+ {setupFrameworkGroups.map((group) => ( +
+ {group.name} +
+ {group.frameworkIds.map((frameworkId) => { + const groupFramework = getSetupFramework(frameworkId); + return ( + setSelectedFramework(frameworkId)} + > + {groupFramework.name} + {groupFramework.name} + + ); + })} +
+
))} + + Need Nitro, Fastify, Elysia, Astro, Standalone, OpenTelemetry, or log streaming? The docs cover those as runtime-supported or integration-level recipes. +
, }, - ...(selectedFramework === 'nextjs' ? nextJsSteps : []), - ...(selectedFramework === 'react' ? reactSteps : []), - ...(selectedFramework === 'javascript' ? javascriptSteps : []), - ...(selectedFramework === 'python' ? pythonSteps : []), - ].map((item, index) => ( + installStep, + keysStep, + configStep, + integrationStep, + doneStep, + ].map((item) => (
  • @@ -612,17 +508,18 @@ function GlobeIllustrationInner() { function StackAuthKeys(props: { keys: { projectId: string, publishableClientKey?: string, secretServerKey: string } | null, onGenerateKeys: () => Promise, - type: 'next' | 'raw', + envPreset: EnvSnippetPreset | null, }) { return (
    {props.keys ? ( <> - {props.type === 'next' ? ( - ) : ( void }) { return ( @@ -337,6 +351,32 @@ function QueriesContent() { const [selectedRow, setSelectedRow] = useState(null); const [detailDialogOpen, setDetailDialogOpen] = useState(false); + // Table selection with auto-detection from SQL + const [selectedTable, setSelectedTable] = useState("events"); + + const handleTableChange = useCallback((newTable: QueryTable) => { + setSelectedTable(newTable); + const config2 = TABLE_CONFIGS[newTable]; + setSqlQuery(`SELECT *\nFROM ${config2.sqlTable}\nORDER BY ${config2.defaultOrderBy} DESC\nLIMIT 100`); + }, []); + + // Auto-detect table when SQL is manually edited + useEffect(() => { + const parsed = parseSql(sqlQuery); + if (parsed && parsed.table !== selectedTable) { + setSelectedTable(parsed.table); + } + }, [sqlQuery]); // eslint-disable-line react-hooks/exhaustive-deps + + // Pagination state + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(false); + const [lastQueryRan, setLastQueryRan] = useState(""); + + // Live mode state + const [liveMode, setLiveMode] = useState(false); + const runQueryRef = useRef<(q?: string) => Promise>(); + // Selection state const [selectedFolderId, setSelectedFolderId] = useState(null); const [selectedQueryId, setSelectedQueryId] = useState(null); @@ -367,13 +407,17 @@ function QueriesContent() { .sort((a, b) => a.sortOrder - b.sortOrder); }, [config]); - const runQuery = useCallback(async (queryToRun?: string) => { + const runQuery = useCallback(async (queryToRun?: string, append = false) => { const trimmedQuery = (queryToRun ?? sqlQuery).trim(); if (!trimmedQuery) return; - setLoading(true); + if (append) { + setLoadingMore(true); + } else { + setLoading(true); + setHasQueried(true); + } setError(null); - setHasQueried(true); try { const response = await adminApp.queryAnalytics({ @@ -385,22 +429,56 @@ function QueriesContent() { const newRows = response.result as RowData[]; const newColumns = newRows.length > 0 ? Object.keys(newRows[0]) : []; - setColumns(newColumns); - setRows(newRows); + if (append) { + setRows((prev) => [...prev, ...newRows]); + } else { + setColumns(newColumns); + setRows(newRows); + setLastQueryRan(trimmedQuery); + } + + // Detect if there might be more rows + const limit = parseLimitFromQuery(trimmedQuery); + setHasMore(limit !== null && newRows.length >= limit); } catch (e: unknown) { setError(e); - setColumns([]); - setRows([]); + if (!append) { + setColumns([]); + setRows([]); + } } finally { setLoading(false); + setLoadingMore(false); } }, [adminApp, sqlQuery]); + runQueryRef.current = runQuery; + + const handleLoadMore = useCallback(() => { + if (loadingMore || !hasMore || !lastQueryRan || liveMode) return; + const query = addOffsetToQuery(lastQueryRan, rows.length); + runAsynchronouslyWithAlert(() => runQuery(query, true)); + }, [loadingMore, hasMore, lastQueryRan, liveMode, rows.length, runQuery]); + + // Live mode polling + const LIVE_POLL_MS = 3_000; + + useEffect(() => { + if (!liveMode || !lastQueryRan) return; + const interval = setInterval(() => { + runAsynchronouslyWithAlert(() => runQueryRef.current?.(lastQueryRan) ?? Promise.resolve()); + }, LIVE_POLL_MS); + return () => clearInterval(interval); + }, [liveMode, lastQueryRan]); + const handleSelectQuery = (folderId: string, query: { id: string, displayName: string, sqlQuery: string, description?: string }) => { setSelectedFolderId(folderId); setSelectedQueryId(query.id); setSqlQuery(query.sqlQuery); setError(null); + // Auto-detect table from saved query SQL + const parsed = parseSql(query.sqlQuery); + if (parsed) setSelectedTable(parsed.table); // Run the query immediately after selecting it runAsynchronouslyWithAlert(() => runQuery(query.sqlQuery)); }; @@ -508,6 +586,8 @@ function QueriesContent() { setRows([]); setColumns([]); setError(null); + setLiveMode(false); + setHasMore(false); }; const handleConfirmDelete = async () => { @@ -605,38 +685,57 @@ function QueriesContent() {
    {/* Query input area */}
    -
    -
    -