diff --git a/docs/content/docs/(guides)/apps/analytics.mdx b/docs/content/docs/(guides)/apps/analytics.mdx index fbb570c4f..672274430 100644 --- a/docs/content/docs/(guides)/apps/analytics.mdx +++ b/docs/content/docs/(guides)/apps/analytics.mdx @@ -4,17 +4,15 @@ description: Explore events, session replays, and SQL queries in your project's icon: ChartLine --- -The Analytics app gives you direct access to your project's analytics dataset in Stack Auth. You can inspect raw event tables, explore span traces on a waterfall timeline, run ClickHouse SQL queries, and watch session replays to debug real user behavior. +The Analytics app gives you direct access to your project's analytics dataset in Stack Auth. You can inspect raw event tables, run ClickHouse SQL queries, and watch session replays to debug real user behavior. ## Overview -Analytics is organized into five areas in the dashboard: +Analytics is organized into three areas in the dashboard: - **Tables**: Browse event rows with sorting, search, and incremental loading -- **Traces**: Inspect span trees — sessions, tabs, and your custom spans — on a zoomable waterfall timeline -- **Replays**: Watch session replays and filter by user, team, duration, activity window, and click count -- **Clickmaps**: Visualize where users click on your pages - **Queries**: Run and save reusable ClickHouse SQL queries +- **Replays**: Watch session replays and filter by user, team, duration, activity window, and click count ## How Analytics Works @@ -25,10 +23,8 @@ flowchart LR A[User activity in your app] --> B[Stack event ingestion] B --> C[ClickHouse data] C --> D[Tables view] - C --> E[Traces waterfall] + C --> E[SQL query runner] C --> F[Session replay UI] - C --> G[Clickmaps] - C --> H[SQL query runner] `} /> ### What Gets Tracked @@ -37,8 +33,6 @@ Stack collects both client-side and server-side analytics events: - **Client-side events**: browser interaction events like `$page-view` and `$click` - **Server-side events**: currently `$token-refresh` and `$sign-up-rule-trigger` -- **Session spans**: every sign-in session and replay recording is recorded as a span, with a span per recorded browser tab underneath — forming a tree your telemetry attaches to -- **Custom events and spans**: anything you record with `trackEvent` and `startSpan`/`withSpan` — see [Custom Events & Spans](../concepts/custom-events-and-spans) ## Enabling the Analytics App @@ -59,7 +53,7 @@ To use analytics in your project: After setup, Stack automatically captures client-side `$page-view` and `$click` events. -Replay recording is on by default as part of analytics capture; set `analytics.replays.enabled: false` in your client app config to opt out. +If you want replay recordings, also enable `analytics.replays.enabled` in your client app config. ## Tables @@ -72,25 +66,12 @@ The **Tables** screen is the fastest way to inspect recent analytics records. Use this view when you need to quickly answer "what just happened?" without writing SQL. -## Traces - -The **Traces** screen renders your span trees — sign-in sessions, replay recordings, browser tabs, and your own [custom events and spans](../concepts/custom-events-and-spans) — as a waterfall timeline. - -- Trace list with per-user avatars, durations, and span/event counts; click any span to focus it as its own waterfall -- Waterfall timeline with events as markers inside their parent spans -- Drag on the timeline to zoom to a range, `Cmd/Ctrl + scroll` to zoom at the cursor, horizontal scroll to pan (while zoomed), `Option/Alt + caret` to collapse a whole subtree -- Open spans (not yet ended) render with a fading edge and an `open` badge; spans whose end lies in the future — like a session lasting until its token expires — show elapsed time as of the last load -- Scope toggle (**All** vs **Custom**) to hide system telemetry, plus type search and time ranges of 1 hour to 30 days -- Loads the 3,000 most recent spans and 3,000 most recent events in the selected window; use the Refresh button to reload - -Use Traces to understand how an operation unfolded — which spans overlapped, what events fired inside them, and how frontend activity connects to [backend spans](../concepts/cross-tier-tracing). - ## Queries The **Queries** screen is a ClickHouse SQL workspace for deeper analysis. - Run read-only SQL queries with a timeout budget -- Query the users, events, and spans tables +- Query the users and analytics tables - Save reusable queries into folders - Re-run saved queries with one click - Edit and overwrite saved query definitions @@ -107,9 +88,9 @@ The **Replays** screen helps you move from "an event happened" to "what the user Use replays when metrics alone are not enough to explain user behavior. -### Configuring Replay Recording in the SDK +### Enabling Replay Recording in the SDK -Session replay recording is enabled by default whenever analytics is enabled. You can opt out or tune masking when creating your client app: +Session replay recording is disabled by default. To enable it, pass `analytics.replays.enabled: true` when creating your client app. ```ts import { StackClientApp } from "@stackframe/stack"; @@ -120,7 +101,6 @@ export const stackClientApp = new StackClientApp({ tokenStore: "nextjs-cookie", analytics: { replays: { - // Set to false to opt out of replay recording. Defaults to true. enabled: true, // Optional. Defaults to true. maskAllInputs: true, @@ -134,8 +114,6 @@ export const stackClientApp = new StackClientApp({ ## Best Practices 1. **Use Tables for quick incident triage**: the Tables UI is the fastest way to inspect recent `events` rows without writing SQL. -2. **Use Traces to see cause and effect**: when you need to know how an operation unfolded — what ran, for how long, and what fired inside it — the waterfall beats scanning rows. -3. **Use Queries for repeatable analysis**: save important SQL in folders, and scope queries with filters/`LIMIT` so they stay within result and timeout limits. -4. **Use Replays for behavioral debugging**: start from an event pattern, then inspect matching session replays to understand what users actually did. -5. **Keep replay privacy defaults on**: leave `maskAllInputs` enabled unless you have a specific reason and a data-handling policy for unmasked inputs. -6. **Instrument the flows you care about**: a few well-named [custom spans](../concepts/custom-events-and-spans) around checkout, onboarding, or imports make Traces and SQL dramatically more useful than raw page views alone. +2. **Use Queries for repeatable analysis**: save important SQL in folders, and scope queries with filters/`LIMIT` so they stay within result and timeout limits. +3. **Use Replays for behavioral debugging**: start from an event pattern, then inspect matching session replays to understand what users actually did. +4. **Keep replay privacy defaults on**: leave `maskAllInputs` enabled unless you have a specific reason and a data-handling policy for unmasked inputs. diff --git a/docs/content/docs/(guides)/concepts/cross-tier-tracing.mdx b/docs/content/docs/(guides)/concepts/cross-tier-tracing.mdx deleted file mode 100644 index 1992b55e8..000000000 --- a/docs/content/docs/(guides)/concepts/cross-tier-tracing.mdx +++ /dev/null @@ -1,129 +0,0 @@ ---- -title: Cross-Tier Tracing -description: Backend spans automatically link to the client session that caused them — no glue code -icon: Network ---- - -When your frontend calls your backend, Stack Auth can automatically parent the backend's telemetry under the caller's client session — the same `$refresh-token` → `$session-replay` → `$session-replay-segment` tree your client-side [events and spans](./custom-events-and-spans) attach to. One trace then spans both tiers: the button click, the API call, the database work behind it. - -## How It Works - ->S: fetch("/api/checkout") + x-hexclave-span-context header (automatic) - S->>S: withSpan("checkout-api", { request }, fn) - S->>C: telemetry batch with resolved session context - C->>C: composes the span tree -`} /> - -Two halves, both automatic once set up: - -1. **Client**: with analytics enabled, the SDK wraps `fetch` and attaches a single header — `x-hexclave-span-context` — to requests going to your own origin. It carries the current tab's segment id plus any ambient custom spans (enclosing `withSpan` callbacks and global spans). -2. **Server**: passing the incoming request to `withSpan`/`trackEvent` (or using a [framework adapter](./framework-adapters), which does it for you) resolves the caller's sign-in session from the request and reads the header, so everything recorded inside nests under the caller's session and any client spans that were open when the request was made. - -## The Client Side - -Nothing to do beyond having analytics enabled with a persistent token store (like `"cookie"` or `"nextjs-cookie"`, the usual setups) — the header is attached automatically to same-origin requests. If your API lives on another origin, allowlist it explicitly: - -```tsx -new StackClientApp({ - // ... - analytics: { - spanPropagation: { - targets: ["https://api.example.com"], // exact origins, matched scheme+host+port - // enabled: false, // turn the automatic header off entirely - }, - }, -}); -``` - - -Cross-origin targets receive the header only if their CORS config allows it: add `x-hexclave-span-context` to the server's `Access-Control-Allow-Headers`. - - -Details worth knowing: - -- Requests with `mode: "no-cors"` are skipped (browsers strip custom headers there anyway). -- If you set the `x-hexclave-span-context` header yourself, the SDK never overwrites it — your intent wins. -- The wrapper chains through the existing `fetch`, so it composes with other wrappers (Sentry, OpenTelemetry); if anything about propagation fails, the request goes out untouched. - -### Other transports - -For XHR, `sendBeacon`, WebSocket handshakes, or manually-built requests, get the header yourself: - -```tsx -const headers = stackClientApp.getSpanPropagationHeaders(); -// { "x-hexclave-span-context": "v1...." } — or {} when there is nothing to propagate - -// pin to a specific span instead of the ambient context: -const pinned = span.getPropagationHeaders(); -``` - -`getSpanPropagationHeaders({ root: true })` drops the ambient custom spans but still carries the tab segment id — session attribution is identity, not parenting. `span.fetch(url)` is a convenience that performs a `fetch` with the header pinned to that span, following the same origin policy. - -## The Server Side - -Pass the incoming request to `withSpan` (or `trackEvent`): - -```tsx -import { stackServerApp } from "@/stack/server"; - -export async function POST(request: Request) { - return await stackServerApp.withSpan("checkout-api", { request }, async (span) => { - // this span — and everything created inside the callback — - // is parented under the caller's client session - await span.trackEvent("payment-authorized", { amount: 129.99 }); - return Response.json({ ok: true }); - }); -} -``` - -With `request`, the SDK resolves: - -- **From the session** (trusted): the caller's sign-in session (`$refresh-token` parent) and user id, read from the request's auth tokens. `userId` is derived automatically unless you override it. -- **From the header** (labels): the tab segment id and any client-side custom parent spans. A header naming a different project is ignored wholesale. - -Anything accepted as `RequestLike` works: a fetch `Request`, Next.js `NextRequest`, or any object whose `headers` either exposes a `get(name)` method or is a plain record of string values. (Node's raw `req.headers` types values as `string | string[]`, so pass a small wrapper rather than the raw `req` — or use a [framework adapter](./framework-adapters).) Unauthenticated requests are fine — the span still records, just without session parents. - -`startSpan` is synchronous and cannot resolve a request — use `withSpan(type, { request }, fn)` for request-linked spans. - - -If you use tRPC, oRPC, Elysia, or Convex, skip the manual `{ request }` entirely — the [framework adapters](./framework-adapters) resolve the caller and wrap your procedures in request-linked spans automatically. - - -## Multi-Hop Propagation - -To continue a trace across your own services, propagate from a server span: - -```tsx -await stackServerApp.withSpan("orchestrate", { request }, async (span) => { - // server→server fetch with the span context attached: - await span.fetch("https://internal.example.com/work"); - - // or for any other transport: - const headers = span.getPropagationHeaders(); -}); -``` - -On the server, `span.fetch` bypasses the origin allowlist (there is no CORS between your own services, and calling it is explicit intent). The downstream service links up the same way — `withSpan(type, { request }, fn)`. - -## Trust Model - - -The propagation header is client-controlled. The sign-in session and user id are resolved server-side from auth tokens and are trustworthy; the segment id and custom parent ids are best-effort labels. Use them for telemetry and debugging — never for authorization, billing, or security decisions. - - -Malformed, oversized, or unknown-version headers are silently ignored — propagation never causes a request to fail. - -## Options Reference - -Client-side, on the `analytics` constructor option: - -| Option | Default | What it does | -|---|---|---| -| `spanPropagation.enabled` | `true` | Auto-attach the header to outgoing `fetch` calls | -| `spanPropagation.targets` | `[]` | Extra origins (besides same-origin) that receive the header; exact matches | -| `ambientParenting` | `"exact"` | Which client spans count as ambient parents — see [Custom Events & Spans](./custom-events-and-spans#exact-vs-best-effort-ambient-parenting) | diff --git a/docs/content/docs/(guides)/concepts/custom-events-and-spans.mdx b/docs/content/docs/(guides)/concepts/custom-events-and-spans.mdx deleted file mode 100644 index bb98826c9..000000000 --- a/docs/content/docs/(guides)/concepts/custom-events-and-spans.mdx +++ /dev/null @@ -1,239 +0,0 @@ ---- -title: Custom Events & Spans -description: Record your own product telemetry — events and spans that automatically join Stack Auth's session tree -icon: Activity ---- - -Stack Auth's analytics dataset isn't limited to built-in events like `$page-view` and `$click`. With `trackEvent` and `startSpan`/`withSpan`, you can record your own **events** (points in time) and **spans** (intervals with a start and end), and they automatically nest under the user's session — so a `checkout` span you create in the browser lands in the same tree as the session replay and sign-in session it happened in. - -## Overview - -- **`trackEvent`**: record a point-in-time fact, like `item-added` or `export-finished` -- **`startSpan` / `withSpan`**: record an interval, like a checkout flow or a background job -- **Automatic parenting**: your telemetry nests under the current session, tab, and any enclosing spans without manual wiring -- **Queryable**: everything lands in ClickHouse — inspect it visually in **Analytics → Traces** or query it with SQL in **Analytics → Queries** - -Custom events and spans require the Analytics app to be enabled (**Apps → Analytics** in your dashboard) and count toward your analytics event quota. - -## How It Works - -Every user session forms a span tree. Stack Auth writes the system levels for you; your custom telemetry attaches underneath: - - B["$session-replay — one replay recording"] - B --> C["$session-replay-segment — one browser tab"] - C --> D["your custom spans and events"] - D --> E["nested custom spans and events"] -`} /> - -Each row in the dataset carries a `parent_span_ids` array — the deduped, root-first chain of ancestor span ids. Span ids are prefixed by type (`rti-` for refresh tokens, `sri-` for session replays, `srsi-` for tab segments, `cs-` for custom spans), while scalar columns like `refresh_token_id` and `user_id` stay raw UUIDs. - -| Span type | Id | Parents | Meaning | -|---|---|---|---| -| `$refresh-token` | `rti-` | — | One sign-in session; ends when the token expires | -| `$session-replay` | `sri-` | `rti-` | One session replay recording | -| `$session-replay-segment` | `srsi-` | `rti-`, `sri-` | One browser tab | -| your custom types | `cs-` | the above + custom ancestors | Whatever you record | - -## Tracking Events - -Call `trackEvent` anywhere you have your app instance: - -```tsx -import { stackClientApp } from "@/stack/client"; - -stackClientApp.trackEvent("item-added", { sku: "T-100", quantity: 2 }); -``` - -Events are buffered and sent in batches (every 10 seconds in the browser, or sooner when the buffer fills). The returned promise resolves when the batch carrying the event is acknowledged — which can be up to one flush interval later — and is safe to ignore: `trackEvent` never throws, and invalid input yields a rejected (pre-caught) promise plus a console error. Call `stackClientApp.flush()` to send everything immediately instead of awaiting individual calls. - -Events work even for signed-out visitors: batches are attributed to an anonymous user until they sign in. - -## Creating Spans - -For operations with a duration, prefer `withSpan` — it starts a span, makes it the ambient parent for everything created inside the callback, and ends it automatically when the callback settles: - -```tsx -await stackClientApp.withSpan("checkout", async (span) => { - stackClientApp.trackEvent("checkout-started"); // nests under the span - await submitOrder(); - span.setData({ total: 129.99 }); // fire-and-forget; awaiting would wait for the next batch send -}); -``` - -If the callback throws, the error message is recorded on the span's `data.error` and the error is rethrown — telemetry never changes your code's behavior. - -For spans that don't fit a single callback (multi-step flows, long-lived operations), use `startSpan` and end it yourself: - -```tsx -const span = stackClientApp.startSpan("onboarding", { data: { plan: "team" } }); -// ... later, possibly in another event handler: -await span.end(); -``` - -A span is written immediately as an open interval and re-written whenever its data changes or it ends. A span that is never ended (for example, the tab closed) stays visible as an open interval — that's a feature, not data loss. - -### The span handle - -`startSpan` and `withSpan` both give you a `Span` handle: - -| Member | What it does | -|---|---| -| `spanId`, `spanType`, `isEnded` | Identity and state | -| `setData(data)` | Shallow-merges into the span's data and re-writes it | -| `end(options?)` | Ends the span (idempotent); an explicit `endedAtMs` is clamped to never precede the start | -| `trackEvent(type, data?, options?)` | Records an event with this span (and its full ancestor chain) as parent | -| `startSpan(type, options?)` / `withSpan(type, fn)` | Creates a child span | -| `run(fn)` | Re-enters the span as the ambient parent for `fn` — for timers and third-party callbacks | -| `ref()` | A serializable `{ spanId, parentSpanIds }` reference you can store or pass around | -| `getPropagationHeaders()` / `fetch(input, init?)` | Cross-tier propagation pinned to this span — see [Cross-Tier Tracing](./cross-tier-tracing) | - -Like `trackEvent`, no span method ever throws, and all returned promises are pre-caught. Where analytics is unavailable — outside the browser, with analytics disabled, or when the app has no persistent token store (e.g. `tokenStore: "memory"`) — `startSpan` returns an inert span whose methods succeed as no-ops, so isomorphic code never needs to branch. - -## Parenting - -### Ambient parents - -When you track an event or start a span, its parents are resolved from context automatically: - -1. **Global spans** — anything registered with `stackClientApp.setGlobalSpan(span)`; useful for app-wide context like "current document open". Ending a span automatically unregisters it. -2. **Enclosing `withSpan` callbacks** — outermost first. -3. **The session tree** — the current tab segment, replay, and sign-in session are attached server-side. - -Explicit `parentIds` are additive on top of the ambient ones. - -### Exact vs. best-effort ambient parenting - -On the server, ambient parenting is backed by `AsyncLocalStorage`, so it is exact across `await`s and under any concurrency. Browsers have no equivalent primitive, so the SDK defaults to **exact mode**: inside a `withSpan` callback, app-level calls (`stackClientApp.trackEvent(...)`) see the span as an ambient parent only until the callback's first `await`. Calls made through the **span handle** are exact everywhere, always: - -```tsx -await stackClientApp.withSpan("checkout", async (span) => { - stackClientApp.trackEvent("step-1"); // linked (before the first await) - await fetch("/api/pay"); - stackClientApp.trackEvent("step-2"); // NOT linked in exact mode - span.trackEvent("step-3"); // linked — handle calls are always exact -}); -``` - -If you prefer zero-glue linking across `await`s in the browser and accept that concurrent flows may occasionally cross-parent, opt into best-effort mode: - -```tsx -new StackClientApp({ - // ... - analytics: { ambientParenting: "best-effort" }, -}); -``` - - -In exact mode a parent is only attached when it provably belongs to the current flow — a wrong parent is worse than a missing one. When in doubt, use the span handle (`span.trackEvent`, `span.withSpan`, `span.run`): it is exact in both modes and in every environment. - - -### Explicit control - -All tracking calls accept parenting options: - -```tsx -stackClientApp.trackEvent("audit-log", { action: "delete" }, { - parentIds: [importSpan], // add explicit parents - root: true, // drop ALL ambient parents; only parentIds apply -}); -``` - -- `parentIds`: an array of `Span` handles, `ref()` objects, or raw span-id strings. Handles and refs contribute their full ancestor chain; a raw string contributes only itself. -- `root: true`: drops all ambient parents (global spans and enclosing `withSpan` frames). Session attribution is identity, not parenting — it is always kept. -- `excludeParentIds`: removes specific spans from the final merged parent list. - -A span's ancestor chain is frozen at creation, deduped root-first, and capped at 10 entries (the nearest ancestors are kept). - -## Server-Side Telemetry - -The same API exists on `StackServerApp`, with two differences: attribution is explicit, and there is no page-lifetime flush cadence. - -```tsx -import { stackServerApp } from "@/stack/server"; - -await stackServerApp.withSpan("import-job", { userId: user.id }, async (span) => { - const rows = await importRows(); - await span.trackEvent("rows-imported", { count: rows.length }); -}); -``` - -- **Attribution**: pass `userId` (a user UUID) to attribute telemetry to a user; child spans and span-attached events inherit it. To attribute to the calling user *and* link into their client session automatically, pass the incoming request — see [Cross-Tier Tracing](./cross-tier-tracing) — or use a [framework adapter](./framework-adapters), which does it for you. -- **Delivery**: server telemetry coalesces on the next microtask (a loop of `trackEvent` calls costs one request, not N). There is no interval timer — `await` the returned promises or call `await stackServerApp.flush()` before your process exits. - -On serverless platforms, wire the `waitUntil` hook so fire-and-forget telemetry survives runtime teardown without awaiting every call: - -```tsx -import { waitUntil } from "@vercel/functions"; - -const stackServerApp = new StackServerApp({ - // ... - analytics: { waitUntil }, // Cloudflare Workers: (p) => ctx.waitUntil(p) -}); -``` - - -`setGlobalSpan` is app-instance state. On a server handling concurrent requests through one shared app instance, a global span set in one request becomes a parent in all of them — prefer explicit `parentIds` or the span handle on servers. - - -## Validation Rules & Limits - -| Rule | Value | -|---|---| -| Event/span type names | Start with a letter; then letters, digits, `_`, `.`, `:`, `-`; at most 64 characters. `$`-prefixed names are reserved for system telemetry. | -| `data` payloads | Plain JSON-serializable object, at most 16,000 bytes serialized (for spans, the limit applies to the accumulated merged data) | -| Parent chain | At most 10 ancestors; on overflow the nearest 10 are kept (with a console warning) | -| Parent ids / `userId` | Must be UUIDs | -| Browser flush | Every 10 seconds, or at 50 buffered items / ~64 KB, plus on tab hide | -| Server flush | Next microtask; no timer — `await` or `flush()` is the delivery guarantee | - -Invalid input never throws: `trackEvent` returns a rejected (pre-caught) promise and `startSpan`/`withSpan` log an error and return an inert span. - -## Querying Your Telemetry - -Everything is queryable in **Analytics → Queries**. Spans live in the `spans` view, events in `events`. Ids in `id`/`parent_span_ids` are prefixed; scalar columns (`user_id`, `refresh_token_id`, `session_replay_id`, `session_replay_segment_id`) are raw UUIDs. - -Duration stats per custom span type: - -```sql -SELECT - span_type, - count() AS total, - countIf(span_ended_at IS NULL) AS still_open, - avgIf(dateDiff('millisecond', span_started_at, span_ended_at), span_ended_at IS NOT NULL) AS avg_ms -FROM spans -WHERE NOT startsWith(span_type, '$') -GROUP BY span_type -ORDER BY total DESC; -``` - -All events inside a given custom span (note the `cs-` prefix when matching against `parent_span_ids`): - -```sql -SELECT event_type, event_at, data, user_id -FROM events -WHERE has(parent_span_ids, 'cs-1f9f9c58-6a3a-4d2b-9d6e-3f0a5b7c8d9e') -- 'cs-' + your span's id -ORDER BY event_at; -``` - -The full span tree for one sign-in session: - -```sql -SELECT id, span_type, span_started_at, span_ended_at, parent_span_ids -FROM spans -WHERE refresh_token_id = '5a3c1b2d-4e5f-4a6b-8c7d-9e0f1a2b3c4d' -- a refresh token id -ORDER BY span_started_at; -``` - -Things to know when writing queries: - -- `events.data` is a native ClickHouse JSON column (`data.url` works); `spans.data` is a JSON **string** — use `JSONExtractString(data, 'key')` and friends. -- Open spans have `span_ended_at IS NULL`. `$refresh-token` spans end at the token's *expiry*, which is often in the future — it is not a "last activity" timestamp. -- Custom span updates are last-`updated_at_ms`-wins: re-writes of the same span collapse to the latest version automatically. - -## Next Steps - -- [Cross-Tier Tracing](./cross-tier-tracing) — link backend spans to the client session that caused them -- [Framework Adapters](./framework-adapters) — tRPC, oRPC, Elysia, and Convex integration in one line -- [Analytics app](../apps/analytics) — the Traces, Tables, and Queries views in the dashboard diff --git a/docs/content/docs/(guides)/concepts/framework-adapters.mdx b/docs/content/docs/(guides)/concepts/framework-adapters.mdx deleted file mode 100644 index 0b72b4d79..000000000 --- a/docs/content/docs/(guides)/concepts/framework-adapters.mdx +++ /dev/null @@ -1,157 +0,0 @@ ---- -title: Framework Adapters -description: One-line tRPC, oRPC, Elysia, and Convex integration — auth and session-linked telemetry together -icon: Plug ---- - -The framework adapters wire Stack Auth into your backend framework in one place. Each adapter does two things with every incoming call: it resolves the authenticated caller (available as `user` on your context) and wraps the handler in a span that is automatically linked to the caller's client session via [cross-tier tracing](./cross-tier-tracing) — you never pass a request object yourself. (Convex is the one exception on the tracing half: its spans are user-attributed only, see below.) - -All adapters ship as subpath exports of every Stack Auth SDK package — import from `@stackframe/js/trpc`, `@stackframe/js/orpc`, `@stackframe/js/elysia`, or `@stackframe/js/convex` (the same subpaths exist on the React, Next.js, and TanStack Start packages). They have zero runtime dependency on the frameworks they adapt: you hand them your own framework instances. - -## Common Options - -Every adapter accepts the same telemetry knob wherever it creates spans: - -```ts -telemetry?: boolean | { spanType?: string, data?: Record } -``` - -- `true` (the default) uses the adapter's span type; `false` disables the span but still resolves the user and enforces `required`. -- The object form overrides the span type or merges extra fields into the span's data. - -Span types are deliberately low-cardinality — the variable parts go in the span's data: - -| Adapter | Span type | Data | -|---|---|---| -| tRPC | `trpc.procedure` | `path`, `type` | -| oRPC | `orpc.procedure` | `path` | -| Elysia | `elysia.route` | `path`, `method` | -| Convex | `convex.function` | `kind`, `name` | - -Adapters marked `required: true` reject unauthenticated calls before your handler runs, and the `user` your handler sees is then non-null. - -## tRPC - -Targets tRPC v11. The factory takes your `t` instance from `initTRPC` — the adapter never imports `@trpc/server`: - -```ts title="server/trpc.ts" -import { initTRPC } from "@trpc/server"; -import { createHexclaveTRPC, type HexclaveTRPCContext } from "@stackframe/js/trpc"; -import { stackServerApp } from "./stack"; - -const t = initTRPC.context().create(); -const hexclave = createHexclaveTRPC(t, stackServerApp); - -export const createContext = hexclave.createContext; // pass to your tRPC adapter -export const router = t.router; -export const publicProcedure = t.procedure.use(hexclave.middleware()); -export const protectedProcedure = t.procedure.use(hexclave.middleware({ required: true })); -``` - -Procedures then have `ctx.user` (the caller, or `null` on public procedures) and run inside a `trpc.procedure` span linked to the caller's session: - -```ts -export const appRouter = router({ - createOrder: protectedProcedure.mutation(async ({ ctx, input }) => { - // ctx.user is non-null here - }), -}); -``` - -Middleware options: `required` (reject unauthenticated calls), `telemetry`, `unauthorized` (error factory), and `getRequest` (custom request extraction if you don't use `createContext`). Without a custom `unauthorized`, rejections throw an error shaped exactly like a `TRPCError` with code `UNAUTHORIZED`, which tRPC's error handling accepts as-is. - -User resolution is memoized per request — `createContext` itself does no auth work; the middleware resolves the user once per request, for every procedure it wraps, so `ctx.user` is ready in your handler. - - -Wire up `createContext` (or pass `getRequest`). Without it the middleware has no request to authenticate against, so every call quietly resolves `ctx.user` as `null` — and `required: true` rejects everything. - - -## oRPC - -The factory returns a middleware for `.use(...)` plus a handler wrapper. The wrapper is required for request linking: oRPC middlewares can't see the raw `Request`, so `wrapFetchHandler` feeds it in through the initial context: - -```ts title="server/orpc.ts" -import { os, ORPCError } from "@orpc/server"; -import { RPCHandler } from "@orpc/server/fetch"; -import { createHexclaveORPC, type HexclaveORPCContext } from "@stackframe/js/orpc"; -import { router } from "./router"; // your router, built from the bases below -import { stackServerApp } from "./stack"; - -const hexclave = createHexclaveORPC(stackServerApp, { - unauthorized: () => new ORPCError("UNAUTHORIZED"), -}); - -export const publicBase = os.$context().use(hexclave.middleware()); -export const protectedBase = publicBase.use(hexclave.middleware({ required: true })); - -const handler = new RPCHandler(router); -export const handle = hexclave.wrapFetchHandler(handler, { prefix: "/rpc" }); -``` - -Procedures receive `context.user` and `context.hexclave`, and run inside an `orpc.procedure` span. `wrapFetchHandler` is not optional decoration: oRPC middlewares never see the raw `Request`, so without the wrapper every call quietly resolves as unauthenticated. - - -`required: true` needs your `unauthorized` factory. oRPC's `ORPCError` is constructor-branded, so the adapter deliberately does not fake one — build the error from your own `@orpc/server` import. Creating a required middleware without a factory throws immediately at setup time, not per request. - - -## Elysia - -Elysia has no around-middleware, so the adapter is three composable pieces: a `.resolve()` callback for the user, a `beforeHandle` guard, and a handler wrapper that puts your route inside the span: - -```ts title="server/app.ts" -import { Elysia } from "elysia"; -import { createHexclaveElysia } from "@stackframe/js/elysia"; -import { stackServerApp } from "./stack"; - -const hexclave = createHexclaveElysia(stackServerApp); - -new Elysia() - .resolve(hexclave.resolveUser) // adds `user` and `hexclave` to every route's context - .get("/me", ({ user }) => user, { beforeHandle: hexclave.requireUser }) - .post("/checkout", hexclave.handler(async ({ user }) => { - // runs inside an `elysia.route` span linked to the caller's session - }), { beforeHandle: hexclave.requireUser }); -``` - -- `resolveUser` resolves the caller once per request and exposes `user` (or `null`). -- `requireUser` short-circuits unauthenticated calls with a `401` and a JSON error body. It reads the `user` that `resolveUser` provides, so it only works on apps (or plugins) where `.resolve(hexclave.resolveUser)` ran — without it, every call gets a `401`. -- `handler(fn, { telemetry? })` wraps the route in the span; use it on routes you want traced. The span works even without `resolveUser`, since the request is taken from Elysia's context. - -## Convex - -Convex is the special case: clients talk to Convex over WebSockets and functions receive no request object, so spans are attributed to the **user only** — they don't (yet) nest under the caller's browser tab. - -First wire up auth (see the [Convex integration guide](../others/convex) for the full setup): - -```ts title="convex/auth.config.ts" -import { getConvexProvidersConfig } from "@stackframe/js/convex"; - -export default { - providers: getConvexProvidersConfig({ projectId: process.env.STACK_PROJECT_ID! }), -}; -``` - -Then wrap your function handlers: - -```ts title="convex/items.ts" -import { query } from "./_generated/server"; -import { hexclaveConvexFunction } from "@stackframe/js/convex"; -import { stackServerApp } from "./stack"; - -export const listItems = query({ - handler: hexclaveConvexFunction(stackServerApp, async ({ ctx, args, user }) => { - // user: the authenticated caller (non-null with { required: true }) - }, { required: true, kind: "query", name: "listItems" }), -}); -``` - -The wrapped handler receives `{ ctx, args, user }` as a single object. Options: `required`, `kind` (`"query" | "mutation" | "action"`, recorded in the span data), `name` (likewise), `telemetry`, and `unauthorized`. - -## Which Piece Does What - -| | Resolves `user` | Rejects unauthenticated | Request-linked span | -|---|---|---|---| -| tRPC | `middleware()` | `middleware({ required: true })` | `middleware()` + `createContext` | -| oRPC | `middleware()` | `middleware({ required: true })` + `unauthorized` factory | `middleware()` + `wrapFetchHandler` | -| Elysia | `resolveUser` | `requireUser` guard | `handler(fn)` | -| Convex | `hexclaveConvexFunction` | `{ required: true }` | user-attributed only (no request over WebSockets) | diff --git a/docs/content/docs/(guides)/meta.json b/docs/content/docs/(guides)/meta.json index 586d04de2..44c943eed 100644 --- a/docs/content/docs/(guides)/meta.json +++ b/docs/content/docs/(guides)/meta.json @@ -25,9 +25,6 @@ "concepts/api-keys", "concepts/backend-integration", "concepts/custom-user-data", - "concepts/custom-events-and-spans", - "concepts/cross-tier-tracing", - "concepts/framework-adapters", "concepts/sign-up-rules", "concepts/emails", "concepts/jwt", diff --git a/packages/shared/src/ai/unified-prompts/skill-site-prompt-parts/docs-json.generated.ts b/packages/shared/src/ai/unified-prompts/skill-site-prompt-parts/docs-json.generated.ts index a878f75e9..85fb376cb 100644 --- a/packages/shared/src/ai/unified-prompts/skill-site-prompt-parts/docs-json.generated.ts +++ b/packages/shared/src/ai/unified-prompts/skill-site-prompt-parts/docs-json.generated.ts @@ -120,7 +120,16 @@ const docsJson = { }, "guides/apps/emails/overview", "guides/apps/payments/overview", - "guides/apps/analytics/overview", + { + "group": "Analytics", + "icon": "/images/app-icons/analytics.svg", + "pages": [ + "guides/apps/analytics/overview", + "guides/apps/analytics/custom-events-and-spans", + "guides/apps/analytics/cross-tier-tracing", + "guides/apps/analytics/framework-adapters" + ] + }, { "group": "Teams", "icon": "/images/app-icons/teams.svg",