@dasasian/firebase-structured-logger 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +669 -103
  2. package/dist/client/logger.d.ts.map +1 -1
  3. package/dist/client/logger.js +16 -2
  4. package/dist/client/logger.js.map +1 -1
  5. package/dist/functions/httpHandler.d.ts +89 -0
  6. package/dist/functions/httpHandler.d.ts.map +1 -0
  7. package/dist/functions/httpHandler.js +115 -0
  8. package/dist/functions/httpHandler.js.map +1 -0
  9. package/dist/functions/index.d.ts +5 -2
  10. package/dist/functions/index.d.ts.map +1 -1
  11. package/dist/functions/index.js +6 -1
  12. package/dist/functions/index.js.map +1 -1
  13. package/dist/functions/logHandler.d.ts +55 -3
  14. package/dist/functions/logHandler.d.ts.map +1 -1
  15. package/dist/functions/logHandler.js +51 -8
  16. package/dist/functions/logHandler.js.map +1 -1
  17. package/dist/functions/logger.d.ts +13 -0
  18. package/dist/functions/logger.d.ts.map +1 -1
  19. package/dist/functions/logger.js +128 -14
  20. package/dist/functions/logger.js.map +1 -1
  21. package/dist/functions/sourceMapCache.d.ts +39 -3
  22. package/dist/functions/sourceMapCache.d.ts.map +1 -1
  23. package/dist/functions/sourceMapCache.js +176 -12
  24. package/dist/functions/sourceMapCache.js.map +1 -1
  25. package/dist/functions/traceContext.d.ts +54 -0
  26. package/dist/functions/traceContext.d.ts.map +1 -0
  27. package/dist/functions/traceContext.js +141 -0
  28. package/dist/functions/traceContext.js.map +1 -0
  29. package/dist/shared/paths.d.ts +59 -0
  30. package/dist/shared/paths.d.ts.map +1 -0
  31. package/dist/shared/paths.js +126 -0
  32. package/dist/shared/paths.js.map +1 -0
  33. package/dist/tools/index.js +13 -4
  34. package/dist/tools/index.js.map +1 -1
  35. package/dist/tools/uploadSourceMaps.d.ts +24 -1
  36. package/dist/tools/uploadSourceMaps.d.ts.map +1 -1
  37. package/dist/tools/uploadSourceMaps.js +31 -4
  38. package/dist/tools/uploadSourceMaps.js.map +1 -1
  39. package/package.json +12 -6
  40. package/skills/query-logs/SKILL.md +14 -1
package/README.md CHANGED
@@ -13,14 +13,49 @@
13
13
 
14
14
  # @dasasian/firebase-structured-logger
15
15
 
16
- One logging pipeline for a Firebase app, from the browser to Google Cloud Logging: frontend events go to a Cloud Function, production stack traces are **symbolicated** against your source maps, and everything is written as **structured, queryable entries**. Ships a client logger, a Cloud Functions logger, and an `fsl` CLI.
16
+ Your web app crashed at `app-4f2a.js:1:98432`. This tells you it was `Checkout.tsx:42` — in your own Google Cloud project, next to your backend logs. Nothing leaves.
17
17
 
18
- ## Why
18
+ Ships a browser logger, a server logger, and the `fsl` CLI. Built for Firebase, but
19
+ Firebase is optional: the browser half has no Firebase dependency, and the server half
20
+ runs on Cloud Functions, Cloud Run, or any Node server. What it needs is a Google Cloud
21
+ project — that is where the logs live.
19
22
 
20
- - **Symbolicated frontend errors** — minified production traces resolve back to `File.tsx:line:col` using source maps you upload at deploy time. No more chasing `app-4f2a.js:1:98432`.
21
- - **Structured entries** — severity, labels, user / session / screen context, breadcrumbs, and file attachments, all queryable in Cloud Logging.
22
- - **One API across surfaces** — the same `logger.info/warning/error/debug` shape on the client and in Cloud Functions.
23
- - **Local dev loop** — the Functions emulator writes JSONL you can `tail` or query without deploying — and query from Claude with the companion [**firebase-mcp-server**](https://github.com/dasasian/firebase-mcp-server).
23
+ ## One query, both halves
24
+
25
+ Frontend and backend write to the same stream in the same shape, so one filter reads the whole story in order:
26
+
27
+ ```
28
+ labels.userId="<uid>"
29
+ ```
30
+
31
+ ```
32
+ 10:42:03.114 INFO screen=checkout click "Apply code"
33
+ 10:42:03.118 INFO screen=checkout applying discount SAVE20
34
+ 10:42:03.402 INFO fn=applyDiscount started
35
+ 10:42:03.611 ERROR fn=applyDiscount coupon lookup failed: timeout
36
+ 10:42:03.798 ERROR screen=checkout TypeError at Checkout.tsx:42:9
37
+ ```
38
+
39
+ Two of those lines came from a browser and three from a server. You never had to think about that, and you never had to correlate two systems by timestamp to see it.
40
+
41
+ **Every label in that query and those lines was attached automatically.** You write the message; the rest rides along. See [What you get for free](#what-you-get-for-free).
42
+
43
+ ## How it works
44
+
45
+ ```
46
+ Browser Your Cloud Functions Cloud Logging
47
+ ┌────────────────┐ ┌──────────────────────┐ ┌───────────────┐
48
+ │ no credentials │─────────▶│ logFrontendEvent() │───────▶│ your project │
49
+ │ no source maps │ │ credentials + maps │ │ one stream │
50
+ └────────────────┘ ├──────────────────────┤ │ one query │
51
+ │ your own functions │───────▶│ │
52
+ │ withLogging() │ └───────────────┘
53
+ └──────────────────────┘
54
+ ```
55
+
56
+ **Why there is a function in the middle.** A browser cannot write to Cloud Logging — it has no credentials, and you would never ship credentials to a browser. So the frontend needs a door, and `logFrontendEvent` is it. Because every frontend error passes through that door anyway, it is also the only place that can hold your source maps: the browser must not have them (`fsl` strips them from `dist/` so they are never published), and Cloud Logging cannot apply them. **Symbolication happens there because there is nowhere else it can happen.**
57
+
58
+ Both boxes in the middle are your own Cloud Functions, in the deploy you already run. Nothing new to stand up.
24
59
 
25
60
  ## Install
26
61
 
@@ -30,11 +65,26 @@ npm install @dasasian/firebase-structured-logger
30
65
 
31
66
  # Cloud Functions
32
67
  cd functions && npm install @dasasian/firebase-structured-logger
68
+
69
+ # Cloud Run, or any Node server
70
+ npm install @dasasian/firebase-structured-logger
33
71
  ```
34
72
 
35
- Ships ESM with three entry points — `/client`, `/functions`, `/tools` — plus the `fsl` CLI. `firebase`, `firebase-admin`, and `firebase-functions` are optional peer dependencies (bring your own versions).
73
+ Ships ESM with three entry points — `/client`, `/functions`, `/tools` — plus the `fsl` CLI. `firebase`, `firebase-admin`, and `firebase-functions` are optional peer dependencies (bring your own versions). None of them is needed to load the package; each only switches on the part that uses it — see [Without Firebase](#without-firebase).
74
+
75
+ ## Setup
76
+
77
+ Most projects do both halves. They share one step — `initLogger` inside `functions/` — and
78
+ are otherwise independent.
36
79
 
37
- ## Quick start
80
+ **Prerequisites**
81
+
82
+ - A `functions/` directory (`firebase init functions`, TypeScript) referenced by `firebase.json`.
83
+ - For browser errors: **Firebase Storage enabled** (source maps are uploaded there) —
84
+ Firebase console → **Storage → Get started** — and a frontend build that emits source maps.
85
+ The `fsl` source-map tooling assumes **Vite**.
86
+
87
+ ### Catching errors from your browser
38
88
 
39
89
  **1. Initialize the client** — at your app entry (e.g. `src/main.tsx`), before any logging:
40
90
 
@@ -52,7 +102,10 @@ export const logger = initLogger({
52
102
  setupGlobalErrorHandler() // capture uncaught errors + unhandled rejections
53
103
  ```
54
104
 
55
- **2. Add the Cloud Function** — in `functions/src/index.ts`:
105
+ `logFunction` is just `(payload) => Promise<unknown>`. `httpsCallable()` happens to fit it —
106
+ anything else that fits will work too.
107
+
108
+ **2. Add the log function** — in `functions/src/index.ts`:
56
109
 
57
110
  ```ts
58
111
  import { initLogger, createClientLogFunction } from '@dasasian/firebase-structured-logger/functions'
@@ -60,107 +113,402 @@ import { initLogger, createClientLogFunction } from '@dasasian/firebase-structur
60
113
  initLogger({ appId: 'my-app' })
61
114
 
62
115
  export const logFrontendEvent = createClientLogFunction({
63
- bucketName: 'my-app.firebasestorage.app', // Storage bucket holding source maps
116
+ bucketName: 'my-app.firebasestorage.app', // holds source maps AND attachments
64
117
  })
65
118
  ```
66
119
 
67
- **3. Log** — anywhere in the frontend:
120
+ One bucket serves both, under two default prefixes:
121
+
122
+ ```
123
+ gs://my-app.firebasestorage.app/sourcemaps/{releaseId}/{bundle}.js.map
124
+ gs://my-app.firebasestorage.app/logAttachments/{logId}/{name}
125
+ ```
126
+
127
+ Both the bucket and the prefix can be changed per half — see [Source maps](#source-maps)
128
+ for `sourceMaps: { bucket, prefix }`, and [Attachments](#attachments) for
129
+ `configureAttachments({ bucket, prefix })`. Omit `bucketName` entirely and both fall back
130
+ to your project's default bucket.
131
+
132
+ **3. Wire the deploy script** — upload source maps and strip them from the hosting bundle as part of deploy. Merge into your root `package.json` scripts, keeping any existing flags like `--project`:
133
+
134
+ ```json
135
+ "deploy": "export VITE_RELEASE_ID=$(git rev-parse --short HEAD) && npm run build && npx fsl upload-sourcemaps --functions=./functions --embed-sourcemaps && firebase deploy"
136
+ ```
137
+
138
+ `fsl upload-sourcemaps` reads the bucket from `VITE_FIREBASE_STORAGE_BUCKET` (or `FIREBASE_STORAGE_BUCKET`) after loading `.env.local`. It uploads source maps to Cloud Storage, embeds a copy in `functions/sourcemaps/current/` for fast lookup, and deletes them from `dist/` so they are **not** served to browsers.
139
+
140
+ > **`VITE_RELEASE_ID`** ties a build to its source maps — the client tags every entry with it, and `upload-sourcemaps` stores maps under the matching path. Use the same value in both places (the deploy script above sets it once from the git SHA). Locally it defaults to `'dev'`, and no maps are uploaded — symbolication isn't needed in development.
141
+
142
+ **4. Verify it works** — prove the round trip before you trust it:
143
+
144
+ ```ts
145
+ import { triggerTestLog } from '@dasasian/firebase-structured-logger/client'
146
+
147
+ triggerTestLog() // wire to a dev-only button; sends one error, one warning, one info
148
+ ```
149
+
150
+ Look for `labels.errorType="fsl-verify"` — in Cloud Logging once deployed, or in `dev.jsonl`
151
+ if you are running the emulator (see [Local development](#local-development)). Three entries,
152
+ and the error's stack should name a source file rather than a minified bundle. If they are
153
+ not there, nothing else in this README will work either.
154
+
155
+ Then log, anywhere in the frontend:
68
156
 
69
157
  ```ts
70
158
  logger.info('checkout started', { orderId })
71
159
  logger.error(err, { screen: 'camera' }, context, { photo: blob }) // attachments optional
72
160
  ```
73
161
 
74
- Debug logs are suppressed in production automatically. That's the happy path — the rest of this doc covers source-map symbolication, user context, the CLI, and the local emulator loop.
162
+ Debug logs are suppressed in production automatically.
75
163
 
76
- ---
164
+ ### Logging from your Cloud Functions
77
165
 
78
- ## How it works
166
+ A complete use of this package on its own — no client, no bundles, no source maps.
79
167
 
168
+ **1. Initialize the logger** — in `functions/src/index.ts`, at module load:
169
+
170
+ ```ts
171
+ import { initLogger } from '@dasasian/firebase-structured-logger/functions'
172
+
173
+ initLogger({ appId: 'my-app' })
80
174
  ```
81
- Browser ──logFrontendEvent()──▶ Cloud Function ──symbolicate──▶ Cloud Logging
82
- (client) httpsCallable (functions) (source maps) (structured)
175
+
176
+ Already done if you set up the browser half — it is the same call.
177
+
178
+ **2. Wrap your handlers** — `withLogging` binds the request's labels for the life of the handler:
179
+
180
+ ```ts
181
+ import { withLogging, logInfo, logError } from '@dasasian/firebase-structured-logger/functions'
182
+
183
+ export const checkout = onCall(
184
+ withLogging({ functionName: 'checkout' }, async (request) => {
185
+ logInfo('started') // carries functionName and the caller's userId already
186
+ ...
187
+ logError(err, { orderId }) // labels merge with the request's
188
+ }),
189
+ )
83
190
  ```
84
191
 
85
- - The **client** batches structured events and calls your `logFrontendEvent` Cloud Function.
86
- - The **function** symbolicates any stack trace against source maps in Cloud Storage, then writes a structured entry (in production) or a local JSONL file (in the emulator).
87
- - You query the result in **Cloud Logging**, or locally from Claude via **[firebase-mcp-server](https://github.com/dasasian/firebase-mcp-server)** (`@dasasian/firebase-mcp-server`).
192
+ `userId` comes from the verified `request.auth.uid`, so you never pass it. The scope unwinds
193
+ when the handler settles — one request's labels can never appear on another's logs, even on a
194
+ warm instance.
88
195
 
89
- ## Full setup
196
+ **3. Verify it works** — call the function, then look for `labels.functionName="checkout"`.
197
+ The entry should carry `userId` without your having written it.
90
198
 
91
- ### Prerequisites
199
+ ### If your backend is not Cloud Functions
92
200
 
93
- - A `functions/` directory (`firebase init functions`, TypeScript) referenced by `firebase.json`.
94
- - **Firebase Storage enabled** (source maps are uploaded there): Firebase console → **Storage → Get started**.
95
- - A frontend build that emits source maps. The `fsl` source-map tooling assumes **Vite**.
201
+ Cloud Run, or any Node server you already run. Two things differ from the browser
202
+ path above; everything else — breadcrumbs, labels, symbolication, the free fields — is
203
+ identical, and the entries land in the same stream in the same shape.
204
+
205
+ **Receive the logs over HTTP** instead of exporting a callable:
206
+
207
+ ```ts
208
+ import express from 'express'
209
+ import { getAuth } from 'firebase-admin/auth'
210
+ import { initLogger, createHttpLogHandler } from '@dasasian/firebase-structured-logger/functions'
211
+
212
+ initLogger({ appId: 'my-app' })
96
213
 
97
- ### 1. `.gitignore`
214
+ const app = express()
215
+ app.use(express.json({ limit: '10mb' })) // attachments ride in the body
216
+
217
+ app.post('/log', createHttpLogHandler({
218
+ bucketName: 'my-app.firebasestorage.app', // holds source maps AND attachments
219
+ authorize: async (req) => {
220
+ const header = String(req.headers.authorization ?? '')
221
+ if (!header.startsWith('Bearer ')) return false
222
+ try { await getAuth().verifyIdToken(header.slice(7)); return true } catch { return false }
223
+ },
224
+ }))
225
+ ```
226
+
227
+ The handler reads Node's request and response shapes. A framework that wraps them, like
228
+ Hono, needs a few lines of adapter:
229
+
230
+ ```ts
231
+ import { Hono } from 'hono'
232
+ import { createHttpLogHandler } from '@dasasian/firebase-structured-logger/functions'
233
+
234
+ const app = new Hono()
235
+ const logHandler = createHttpLogHandler({ authorize })
236
+
237
+ app.on(['POST', 'OPTIONS'], '/log', async (c) => {
238
+ const headers: Record<string, string> = {}
239
+ let status = 200
240
+ let body: string | undefined
241
+ await logHandler(
242
+ {
243
+ method: c.req.method,
244
+ headers: Object.fromEntries(c.req.raw.headers),
245
+ body: await c.req.json().catch(() => null),
246
+ },
247
+ {
248
+ get statusCode() { return status },
249
+ set statusCode(v: number) { status = v },
250
+ setHeader: (name, value) => { headers[name] = value },
251
+ end: (b) => { body = b },
252
+ },
253
+ )
254
+ return new Response(body ?? null, { status, headers })
255
+ })
256
+ ```
257
+
258
+ **Point the client at it.** `logFunction` is any async function, so a `fetch` works:
259
+
260
+ ```ts
261
+ initLogger({
262
+ appId: 'my-app',
263
+ releaseId: import.meta.env.VITE_RELEASE_ID ?? 'dev',
264
+ logFunction: async (payload) => {
265
+ const res = await fetch('https://api.example.com/log', {
266
+ method: 'POST',
267
+ headers: {
268
+ 'Content-Type': 'application/json',
269
+ Authorization: `Bearer ${await auth.currentUser?.getIdToken()}`,
270
+ },
271
+ body: JSON.stringify(payload),
272
+ })
273
+ if (!res.ok) throw new Error(`log rejected: ${res.status}`)
274
+ },
275
+ })
276
+ ```
277
+
278
+ #### Without Firebase
279
+
280
+ Nothing here needs a Firebase project — only a Google Cloud one. What changes:
281
+
282
+ - **The browser** needs no `firebase` package. `logFunction` is the `fetch` above; send
283
+ whatever credential your app already uses.
284
+ - **`authorize`** checks your own session instead of a Firebase ID token — for example
285
+ `authorize: (req) => sessions.isValid(req.headers.cookie)`.
286
+ - **Storage** has no Firebase default bucket to fall back to. Name one with `bucketName`
287
+ (it holds source maps and attachments), and give the service's account access to it.
288
+ Or name none: embedded maps still resolve the current release, older releases stay
289
+ minified, and attachments are dropped — the log says so once.
290
+ - **`fsl upload-sourcemaps --bucket`** works with any Cloud Storage bucket.
291
+ - **`withLogging` and `createClientLogFunction`** are Cloud Functions tools. Outside
292
+ them, `logInfo` / `logError` and friends still write the same entries.
293
+
294
+ #### `authorize` is required, and that is deliberate
295
+
296
+ A callable gets Firebase's token check for free. An HTTP endpoint gets nothing, and an
297
+ open one writes to your Cloud Logging bill on anyone's say-so. There is no honest
298
+ default, so there isn't one.
299
+
300
+ It is a **gate, not an identity check**. The handler never reads `request.auth` — the
301
+ `userId` on a client entry is self-reported either way. Its job is keeping strangers out.
302
+
303
+ If something in front of it already did that work — a VPC, an API gateway, IAM — say so
304
+ at the call site:
305
+
306
+ ```ts
307
+ createHttpLogHandler({ authorize: 'unauthenticated' })
308
+ ```
309
+
310
+ A gate that throws counts as a rejection, not an opening.
311
+
312
+ #### What you need to know
313
+
314
+ - **Body parsing is yours.** Mount `express.json()` (or your framework's equivalent)
315
+ before the handler. Raise its limit if you send attachments.
316
+ - **CORS** defaults to `*`, matching `cors: true` on the callable. Pass `allowOrigin` to
317
+ name your origin — a browser cannot send cookies to a wildcard.
318
+ - **No `firebase-functions`? Not needed.** The entry point loads without it. Each entry is
319
+ written as one line of JSON, which Cloud Run's log agent parses into the same Cloud
320
+ Logging fields a function's would.
321
+ - **Trace correlation works**, and needs nothing from you on Cloud Run. The handler reads
322
+ `X-Cloud-Trace-Context` or `traceparent` off the request, and asks the metadata server
323
+ for the project id once, so a request's entries group with Cloud Run's own request log
324
+ in Cloud Logging. Somewhere else — GKE, a VM — set `GOOGLE_CLOUD_PROJECT`; without it
325
+ the trace id is still written, but does not join the platform's request log.
326
+ - **No `firebase-admin`? Also optional.** Storage is only needed for older releases'
327
+ source maps and for attachments. With `firebase-admin` installed, its Storage and
328
+ default bucket are used. Without it, name the bucket — `bucketName` on the handler, or
329
+ `configureAttachments({ bucket })` — and the service's own credentials are used.
330
+ - **No Storage bucket?** You do not need one. `fsl upload-sourcemaps --embed-sourcemaps`
331
+ without `--bucket` embeds the current release's maps into your deploy and uploads
332
+ nothing. The catch: only the **deployed** release can be symbolicated, because older
333
+ ones live in a bucket there isn't one of. Errors from a previous release come back
334
+ minified, and attachments are dropped — the entry is still written. With neither
335
+ `firebase-admin` nor a bucket name, the log says so once at the first lookup.
336
+ - **Response codes:** `204` written, `400` malformed payload, `401` gate refused, `405`
337
+ not a POST, `500` something else. The client treats a non-2xx as a throw.
338
+
339
+ ## Local development
340
+
341
+ The Functions emulator writes the same entries to a local JSONL file instead of Cloud
342
+ Logging, so the whole loop — client, log function, symbolication path, labels — works
343
+ before you deploy anything.
344
+
345
+ Add a `serve` script to `functions/package.json`:
346
+
347
+ ```json
348
+ "serve": "npm run build && firebase emulators:start --only functions"
349
+ ```
350
+
351
+ Then tell the logger where to write, in your functions entry point:
352
+
353
+ ```ts
354
+ initLogger({ appId: 'my-app', logLocalDir: 'logs' })
355
+ ```
356
+
357
+ `logLocalDir` is yours to choose — it is resolved against the emulator's working directory,
358
+ which is `functions/`. `functions/logs` is the convention used throughout this README, not a
359
+ requirement; anywhere writable works, including a path outside the project.
360
+
361
+ Point **[firebase-mcp-server](https://github.com/dasasian/firebase-mcp-server)** at that file
362
+ to query your dev logs from Claude exactly as you would query Cloud Logging.
363
+
364
+ ### Keep the logs out of git
98
365
 
99
366
  ```
100
367
  functions/logs/*.jsonl
101
368
  ```
102
369
 
103
- The `logs/` directory holds local JSONL from the emulator — track the directory, not the files:
370
+ Track the directory, not the files:
104
371
 
105
372
  ```bash
106
373
  mkdir -p functions/logs && touch functions/logs/.gitkeep
107
374
  ```
108
375
 
109
- ### 2. Wire the deploy script
376
+ ### Rotation
110
377
 
111
- Upload source maps (and strip them from the hosting bundle) as part of deploy. Merge into your root `package.json` scripts — keep any existing flags like `--project`:
378
+ Entries go to `{logLocalDir}/dev.jsonl`. Each emulator start rotates the current file to
379
+ `dev-{timestamp}.jsonl`, and rotation also happens when the record limit is hit mid-session.
112
380
 
113
- ```json
114
- "deploy": "export VITE_RELEASE_ID=$(git rev-parse --short HEAD) && npm run build && npx fsl upload-sourcemaps --functions=./functions --embed-sourcemaps && firebase deploy"
381
+ | Config | Default | Description |
382
+ |---|---|---|
383
+ | `logLocalDir` | — | Directory for local log files |
384
+ | `logMaxRecordsPerFile` | 2000 | Records per file before rotation |
385
+ | `logMaxRotatedFiles` | 5 | Rotated files to keep |
386
+
387
+ ## Grouping, without a second product
388
+
389
+ Google Cloud already runs an error tracker in your project. **Error Reporting** watches
390
+ Cloud Logging, collapses repeats into issues, and gives you occurrence counts, a
391
+ resolution state — Open, Acknowledged, Resolved, Muted — notifications on new errors, and a
392
+ field to link your own issue tracker. It costs nothing beyond the logs you are already
393
+ writing, and it never sees anything outside your project.
394
+
395
+ It groups by exception type plus the **five top-most stack frames**. Which is why, for
396
+ almost every web app, it does nothing at all: those frames read `app-4f2a.js:1:98432`, they
397
+ change every release, and no two crashes ever look alike.
398
+
399
+ **We resolve the frames before the entry is written.** So yours read `Checkout.tsx:42`, and
400
+ they group:
401
+
402
+ ```
403
+ TypeError: cannot read 'id' of undefined ← "the discount broke"
404
+ TypeError: order is not iterable ← "checkout is stuck"
405
+ two reports, two messages,
406
+ one line of code, one issue
115
407
  ```
116
408
 
117
- `fsl upload-sourcemaps` reads the bucket from `VITE_FIREBASE_STORAGE_BUCKET` (or `FIREBASE_STORAGE_BUCKET`) after loading `.env.local`. It uploads source maps to Cloud Storage, embeds a copy in `functions/sourcemaps/current/` for fast lookup, and deletes them from `dist/` so they are **not** served to browsers.
409
+ Errors at `ERROR` and above carry `stack_trace` and a `serviceContext` naming your `appId`
410
+ and release, which is all Error Reporting needs. Nothing to enable in this package, and
411
+ nothing to configure.
118
412
 
119
- > **`VITE_RELEASE_ID`** ties a build to its source maps — the client tags every entry with it, and `upload-sourcemaps` stores maps under the matching path. Use the same value in both places (the deploy script above sets it once from the git SHA). Locally it defaults to `'dev'`, and no maps are uploaded — symbolication isn't needed in development.
413
+ Warnings stay out of it, and so does user feedback — an issue is something a person has to
414
+ resolve, and neither of those is a bug.
120
415
 
121
- ### 3. Typed labels + user context
416
+ > Verified end to end against a real project: two errors with different messages from one
417
+ > source location land in a single group, attributed to the `appId` rather than to the
418
+ > function that wrote them, with the same group id across releases.
419
+
420
+ ## What you get for free
421
+
422
+ You write one label. Eleven fields land.
122
423
 
123
424
  ```ts
124
- interface MyAppLabels {
425
+ logger.error(err, { orderId })
426
+ ```
427
+
428
+ | Field | Added by | Where it comes from |
429
+ |---|---|---|
430
+ | `appId`, `releaseId` | client | your `initLogger` config |
431
+ | `screen` | client | tracked as the user moves |
432
+ | `userId` | client | `setUser`, held for the session |
433
+ | `platform` | client | user agent — `ios` / `android` / `macos` / `web` |
434
+ | `browser` | client | user agent |
435
+ | `errorType` | client | the Error's own `name` |
436
+ | last 50 breadcrumbs | client | the trail of what the user did |
437
+ | `logId` | function | a ULID, unique per entry — locates attachments in GCS |
438
+ | `hasAttachments` | function | `"true"` when files were uploaded alongside |
439
+ | resolved file and line | function | your source maps — `Checkout.tsx:42`, not `app-4f2a.js:1:98432` |
440
+ | trace context | function | request correlation in Cloud Logging |
441
+
442
+ Backend logs get the same treatment: `withLogging` attaches `functionName`, `userId` from
443
+ the verified `request.auth.uid`, and whatever else you bind.
444
+
445
+ That is the "structured" in the name. Not that the entry is JSON — that it arrives already
446
+ carrying who, where, which release, and what led up to it.
447
+
448
+ **The rule behind it:**
449
+
450
+ > You never pass context to a log call. You declare it once, and it rides along.
451
+
452
+ On the client that scope is the **session**. On the backend it is the **request**. Same idea,
453
+ two clocks — and it is why `labels.userId="…"` returns both halves: the client attaches the
454
+ uid from `setUser`, the backend from `request.auth.uid`, same label name, no coordination.
455
+
456
+ Declaring context does not replace passing it. There are three scopes, and they merge:
457
+
458
+ ```ts
459
+ initLogger({ appId: 'my-app' }) // every log, for the life of the app
460
+ logger.setUser(uid, { orgId }) // every log, until clearUser()
461
+ logger.error(err, { orderId }) // this log only
462
+ ```
463
+
464
+ Innermost wins — a label passed at the call site overrides the same label from `setUser`.
465
+ The backend works the same way: `withLogging` binds the request's labels, and each
466
+ `logInfo(message, labels)` can add or override for that one line.
467
+
468
+ ## Adding your own context
469
+
470
+ Define your labels once, in a file both the app and `functions/` import:
471
+
472
+ ```ts
473
+ // src/shared/labels.ts
474
+ export interface MyAppLabels {
125
475
  organizationId?: string
126
476
  itemId?: string
127
477
  // whatever domain entities are relevant
128
478
  }
479
+ ```
480
+
481
+ Then use the same type on both sides.
129
482
 
483
+ **Client** — scoped to the session:
484
+
485
+ ```ts
130
486
  export const logger = initLogger<MyAppLabels>({ /* … */ })
131
487
 
132
- logger.setUser(uid, { /* app labels */ }) // on sign in
488
+ logger.setUser(uid, { organizationId }) // on sign in — rides every log until cleared
133
489
  logger.clearUser() // on sign out
134
490
  logger.setScreen('checkout') // on navigation
135
491
  ```
136
492
 
137
- ### 4. Run the emulator (local capture)
138
-
139
- Add a `serve` script to `functions/package.json`:
140
-
141
- ```json
142
- "serve": "npm run build && firebase emulators:start --only functions"
143
- ```
144
-
145
- The emulator writes entries to `DEV_LOG_DIR` (e.g. `functions/logs/dev.jsonl`), so you can inspect logs without deploying — and point **[firebase-mcp-server](https://github.com/dasasian/firebase-mcp-server)** at that same file to query your dev logs from Claude.
146
-
147
- ## Client API
493
+ **Backend** — scoped to the request:
148
494
 
149
495
  ```ts
150
- logger.error(error, labels?, context?, attachments?) // attachments: Record<string, Blob | File | string>
151
- logger.info(message, labels?, context?, attachments?)
152
- logger.warning(message, labels?, context?, attachments?)
153
- logger.debug(message, labels?, context?, attachments?) // suppressed in production
154
-
155
- logger.setUser(uid, extraLabels?)
156
- logger.clearUser()
157
- logger.setScreen(screen)
158
- logger.addBreadcrumb(type, name, data?)
496
+ import { withLogging, logInfo } from '@dasasian/firebase-structured-logger/functions'
497
+
498
+ export const checkout = onCall(
499
+ withLogging<MyAppLabels>(
500
+ (request) => ({ functionName: 'checkout', labels: { organizationId: request.data.orgId } }),
501
+ async (request) => {
502
+ logInfo('started') // carries functionName, userId and organizationId already
503
+ },
504
+ ),
505
+ )
159
506
  ```
160
507
 
161
- **Attachments** (images, snapshots, captured data) upload to GCS at `logAttachments/{logId}/{name}`; the same `logId` is on the entry's `labels.logId`. Add a lifecycle rule to auto-delete `logAttachments/` after N days.
508
+ The function form runs per call, so labels can be derived from the request. The static form
509
+ from [setup](#logging-from-your-cloud-functions) is the same thing without that.
162
510
 
163
- ### Breadcrumbs
511
+ ## Breadcrumbs
164
512
 
165
513
  ```ts
166
514
  import { bc } from '@dasasian/firebase-structured-logger/client'
@@ -191,7 +539,7 @@ current screen, so `labels.screen` stays correct without a second call.
191
539
  > Record the step, not the data. Breadcrumb `data` is written to your logs verbatim — keep
192
540
  > PII, tokens and card numbers out of it, the same as you would for any label.
193
541
 
194
- ### User feedback
542
+ ## User feedback
195
543
 
196
544
  ```ts
197
545
  import { sendFeedback } from '@dasasian/firebase-structured-logger/client'
@@ -208,7 +556,9 @@ didn't apply"* is a complaint; the same sentence plus `nav→Checkout · apply_d
208
556
  total_recalculated · tap_place_order` is a reproduction.
209
557
 
210
558
  It carries everything a log carries — breadcrumbs, `screen`, `userId`, `releaseId`,
211
- `platform`, `browser`, and any labels seeded via `setUser`.
559
+ `platform`, `browser`, and any labels seeded via `setUser`. A screenshot passed as an
560
+ attachment rides the same Cloud Storage path as any other, so it is not bounded by the
561
+ entry size limit — see [Attachments](#attachments).
212
562
 
213
563
  Headless: the package renders nothing, so the UI is yours. It returns nothing either —
214
564
  a reference number is meaningless to a user with no portal to check it against. Say thank
@@ -222,77 +572,212 @@ alert ignores it with no configuration.
222
572
  Feedback is exempt from the severity floor and the rate limiter — those are volume controls
223
573
  for events the system emits, and someone hitting send twice is not a duplicate to throttle.
224
574
 
225
- ## Functions API
575
+ ## Attachments
226
576
 
227
577
  ```ts
228
- import { initLogger, withLogging, logError, logInfo } from '@dasasian/firebase-structured-logger/functions'
578
+ logger.error(err, { orderId }, context, { photo: blob, state: JSON.stringify(cart) })
579
+ ```
229
580
 
230
- initLogger({ appId: 'my-app' }) // at module load
581
+ Any log method takes a final `attachments` argument — `Record<string, Blob | File | string>`
582
+ on the client, `Record<string, string | Buffer>` on the backend.
583
+
584
+ **Attachments are how you send more than a log entry can hold.** A Cloud Logging entry is
585
+ capped at **256 KB**, and a big payload does not get truncated — the write fails. Attachments
586
+ never enter the entry: they are uploaded to Cloud Storage and stripped before the entry is
587
+ written, so a 5 MB screenshot costs the log line two labels. Use them for anything that would
588
+ otherwise blow the cap — screenshots, request bodies, a serialised store, a captured frame.
589
+
590
+ They land at:
231
591
 
232
- export const myFunction = onCall(
233
- withLogging({ functionName: 'myFunction' }, async (request) => {
234
- logInfo('started') // carries userId + functionName automatically
235
- }),
236
- )
237
592
  ```
593
+ gs://<bucket>/logAttachments/{logId}/{name}
594
+ ```
595
+
596
+ `logId` is a ULID on the entry itself, so the log line tells you where its files are:
597
+
598
+ ```
599
+ labels.hasAttachments="true" # entries that have files
600
+ labels.logId="01J..." # the entry whose files you are looking for
601
+ ```
602
+
603
+ By default they share the bucket passed to `createClientLogFunction({ bucketName })` — the
604
+ same one the source maps live in, falling back to the project's default bucket.
238
605
 
239
- `withLogging` binds the request's labels for the duration of the handler and unwinds
240
- afterwards, so one request's `userId` can never appear on another's logs. Labels that
241
- depend on the request are computed per call:
606
+ Send them somewhere else with `configureAttachments`, in your functions entry point:
242
607
 
243
608
  ```ts
244
- withLogging(
245
- (request) => ({ functionName: 'myFunction', labels: { orgId: request.data.orgId } }),
246
- async (request) => { /* … */ },
247
- )
609
+ import { configureAttachments } from '@dasasian/firebase-structured-logger/functions'
610
+
611
+ configureAttachments({ bucket: 'my-app-user-content', prefix: 'evidence' })
248
612
  ```
249
613
 
250
- > **Migrating from `initRequestLogger`?** It was removed in 0.6.0 — wrap the handler in
251
- > `withLogging` instead of calling it as the first line. It bound the scope with
252
- > `enterWith()`, which is never unwound, so the labels outlived the request and a later
253
- > handler that did *not* call it (a scheduled function, a Firestore trigger, or anything
254
- > relying on `getLogger()`'s anonymous fallback) inherited whichever user last touched the
255
- > warm instance. Codebases where every handler called it were unaffected; the hazard was
256
- > the ones that didn't. `withLogging` uses `run()`, which restores the previous scope when
257
- > the handler settles.
614
+ Call it once, at module load. It is global on purpose and global in the API: the upload
615
+ happens on every log call, including ones inside your own handlers that never touch
616
+ `createClientLogFunction`, so there is no per-handler setting for it to read. Fields you
617
+ leave out keep today's behaviour, and never calling it changes nothing.
258
618
 
259
- Backend log methods also accept an optional `attachments` (`Record<string, string | Buffer>`).
619
+ Worth doing when user content needs its own region for residency, its own retention policy,
620
+ or different IAM from your source maps — none of which can be arranged with a prefix.
260
621
 
261
- ## CLI (`fsl`)
622
+ Nothing expires them. Add a lifecycle rule on `logAttachments/` to delete after N days, or
623
+ they accumulate for the life of the project.
262
624
 
263
- ```bash
264
- # Upload source maps to Cloud Storage and strip local .map files (run in deploy)
265
- npx fsl upload-sourcemaps [--bucket=<name>] [--functions=<path>] [--embed-sourcemaps] [--release=<id>]
625
+ ## Volume controls
266
626
 
267
- # Install the Claude Code skills into the current project (or --global, --force)
268
- npx fsl install-skills
627
+ Three separate gates decide whether a log is written. All have defaults, and the defaults
628
+ drop things — so this is worth reading before you conclude something is broken.
629
+
630
+ | Gate | Default | Where |
631
+ |---|---|---|
632
+ | Session limit | **50 logs**, then the client stops sending | client, per browser session |
633
+ | Duplicate limit | **3 copies** of the same error, then it stops | client, per browser session |
634
+ | Client severity floor | `WARNING` in production, `DEBUG` in dev | client, `minLogLevel` |
635
+ | Server severity floor | `WARNING` in production, `DEBUG` in the emulator | function, `minSeverity` |
636
+ | Function concurrency | `maxInstances: 1` on `createClientLogFunction` | function |
637
+
638
+ ```ts
639
+ initLogger({
640
+ appId: 'my-app',
641
+ releaseId,
642
+ logFunction,
643
+ minLogLevel: 'INFO',
644
+ rateLimitOptions: { sessionLimit: 200, duplicateLimit: 5 },
645
+ })
269
646
  ```
270
647
 
271
- > To try an unreleased change in a real deployment, publish a prerelease and install it:
272
- > `npm publish --tag beta`, then `npm i @dasasian/firebase-structured-logger@beta`. For local work against a checkout, `npm link` avoids publishing entirely.
648
+ Two errors count as duplicates when the **message and the screen both match**, so the same
649
+ error on two different screens is not collapsed into one. The budget lives in
650
+ `sessionStorage` and resets with the session.
273
651
 
274
- ## Skills
652
+ The client's production default comes from `process.env.NODE_ENV`, which Vite replaces at
653
+ build time. A `define: { 'process.env': {} }` in `vite.config` — common, to quiet a library
654
+ that expects Node — replaces the whole object instead, `NODE_ENV` reads as undefined, and
655
+ the floor is silently `DEBUG` in production. If your config has that line, pass
656
+ `minLogLevel` explicitly. Stating it is the safe habit either way: it is the one default
657
+ here whose failure mode is a bill rather than a missing log.
658
+
659
+ ### What a dropped log looks like
660
+
661
+ The two rate limits say so in the browser console:
275
662
 
276
- ```bash
277
- npx fsl install-skills
278
663
  ```
664
+ [fsl] Duplicate suppressed: TypeError: cannot read 'id'|checkout
665
+ [fsl] Session log limit reached
666
+ ```
667
+
668
+ **The severity floors are silent.** Both of them — the client's `minLogLevel` and the
669
+ function's `minSeverity` — simply return, with nothing written and nothing logged about it.
670
+
671
+ So if an entry never arrived and there is no `[fsl]` warning in the console, it was a floor,
672
+ not a limit. In production both default to `WARNING`, which drops `DEBUG`, `INFO` and
673
+ `NOTICE` on the way out of the browser *and* again on the way into Cloud Logging — an
674
+ `INFO` you expected to see has two places it can vanish.
675
+
676
+ `maxInstances: 1` is a deliberate cost guard on what is usually the busiest function in the
677
+ system. Raise it (`createClientLogFunction({ bucketName, maxInstances: 5 })`) if you are
678
+ dropping client logs under load — and watch your Cloud Logging bill when you do.
679
+
680
+ Feedback is exempt from every one of these. See [User feedback](#user-feedback).
681
+
682
+ ## Querying
683
+
684
+ One filter, both halves, in time order:
685
+
686
+ ```
687
+ labels.userId="<uid>"
688
+ ```
689
+
690
+ Narrow it when you need to:
691
+
692
+ | Filter | Returns |
693
+ |---|---|
694
+ | `labels.platform:*` | client entries only |
695
+ | `labels.functionName:*` | server entries only |
696
+ | `labels.releaseId="<sha>"` | one build |
697
+ | `labels.screen="checkout"` | one screen |
698
+ | `labels.feedback="true"` | user-reported issues |
699
+ | `labels.hasAttachments="true"` | entries with files in GCS |
700
+
701
+ Locally, the emulator's JSONL answers the same questions. Point
702
+ **[firebase-mcp-server](https://github.com/dasasian/firebase-mcp-server)** at either and ask
703
+ Claude instead — `npx fsl install-skills` installs the two skills below.
279
704
 
280
705
  | Skill | Description |
281
706
  |-------|-------------|
282
707
  | `/logs` | Validate logging in a file — error paths, labels, PII, unwrapped handlers, breadcrumbs |
283
708
  | `/query-logs` | Query Cloud Logging or local JSONL via [firebase-mcp-server](https://github.com/dasasian/firebase-mcp-server) |
284
709
 
285
- ## Local log rotation
710
+ > A stack trace is self-reported by the browser, and so is `userId` on client entries — the
711
+ > uid comes from the client's own labels, not from a verified token. Backend entries are
712
+ > different: `withLogging` reads `request.auth.uid`, which Firebase has verified. Fine for
713
+ > debugging either way; don't build an audit trail on the client half.
286
714
 
287
- Logs write to `{logLocalDir}/dev.jsonl`; each emulator start rotates the current file to `dev-{timestamp}.jsonl`, and rotation also happens when the record limit is hit mid-session.
715
+ ## Reference
288
716
 
289
- | Config | Default | Description |
290
- |---|---|---|
291
- | `logLocalDir` | — | Directory for local log files |
292
- | `logMaxRecordsPerFile` | 2000 | Records per file before rotation |
293
- | `logMaxRotatedFiles` | 5 | Rotated files to keep |
717
+ ### Client
718
+
719
+ ```ts
720
+ logger.error(error, labels?, context?, attachments?) // attachments: Record<string, Blob | File | string>
721
+ logger.info(message, labels?, context?, attachments?)
722
+ logger.warning(message, labels?, context?, attachments?)
723
+ logger.debug(message, labels?, context?, attachments?) // suppressed in production
724
+
725
+ logger.setUser(uid, extraLabels?)
726
+ logger.clearUser()
727
+ logger.setScreen(screen)
728
+ logger.addBreadcrumb(type, name, data?)
729
+ ```
730
+
731
+ Also exported: `initLogger`, `getClientLogger`, `setupGlobalErrorHandler`, `handleReactError`,
732
+ `sendFeedback`, `triggerTestLog`, `addBreadcrumb`, `bc`.
294
733
 
295
- ## Source maps
734
+ `Logger` is exported as a **type only** — the client logger is a session singleton, so
735
+ annotate with `Logger<MyAppLabels>` and construct with `initLogger()`. A second instance
736
+ would silently share breadcrumbs, screen and the rate-limit budget while looking independent.
737
+
738
+
739
+ ### Functions
740
+
741
+ ```ts
742
+ initLogger({ appId, logLocalDir?, minSeverity?, logMaxRecordsPerFile?, logMaxRotatedFiles? })
743
+
744
+ withLogging(options | (request) => options, handler)
745
+ getLogger() // the current request's writer, or an anonymous fallback
746
+ logError / logWarn / logInfo / logDebug (message, labels?, context?, attachments?)
747
+
748
+ configureAttachments({ bucket?, prefix? }) // once, at module load — see Attachments
749
+
750
+ // Receiving client logs. All three take the same source-map config:
751
+ // { bucketName?, sourceMaps?: { bucket?, prefix? } }
752
+ createClientLogFunction({ …, cors?, maxInstances? }) // a ready-to-export callable
753
+ createHttpLogHandler({ …, authorize, allowOrigin? }) // an (req, res) handler for Express etc.
754
+ createClientLogHandler({ … }) // the bare handler, wrap it yourself
755
+ ```
756
+
757
+ Backend log methods also accept an optional `attachments` (`Record<string, string | Buffer>`).
758
+
759
+ `createClientLogHandler` takes `{ data: LogPayload }` — the minimum it reads — and throws
760
+ `ClientLogError` with a `code` of `'invalid-argument'` or `'internal'`. The two wrappers above
761
+ translate that: the callable into an `HttpsError`, the HTTP handler into a status code. Use the
762
+ bare handler only if you are wrapping it in something else yourself.
763
+
764
+ ### CLI (`fsl`)
765
+
766
+ ```bash
767
+ # Upload source maps to Cloud Storage and strip local .map files (run in deploy)
768
+ npx fsl upload-sourcemaps --functions=./functions --embed-sourcemaps
769
+
770
+ # Same, to a bucket and prefix of your choosing — tell the reader the same values
771
+ npx fsl upload-sourcemaps --functions=./functions --embed-sourcemaps --bucket=my-maps --prefix=fsl-maps
772
+
773
+ # No bucket at all: embed the current release, upload nothing
774
+ npx fsl upload-sourcemaps --functions=./backend --embed-sourcemaps
775
+
776
+ # Install the Claude Code skills into the current project (or --global, --force)
777
+ npx fsl install-skills
778
+ ```
779
+
780
+ ### Source maps
296
781
 
297
782
  For symbolicated production traces, emit source maps in Vite and upload them at deploy:
298
783
 
@@ -307,7 +792,88 @@ build: {
307
792
  }
308
793
  ```
309
794
 
310
- Maps are stored at `gs://<bucket>/sourcemaps/{releaseId}/{filename}.map` and loaded by the Cloud Function during symbolication.
795
+ Maps are stored at `gs://<bucket>/sourcemaps/{releaseId}/{filename}.map` and loaded by whichever log handler you deployed — the callable or the HTTP one — during symbolication.
796
+
797
+ To use a different bucket or prefix, tell **both ends** — they are two halves of one contract
798
+ and nothing checks them against each other:
799
+
800
+ ```bash
801
+ npx fsl upload-sourcemaps --bucket=my-maps --prefix=fsl-maps …
802
+ ```
803
+
804
+ ```ts
805
+ createClientLogFunction({ sourceMaps: { bucket: 'my-maps', prefix: 'fsl-maps' } })
806
+ ```
807
+
808
+ If they disagree the maps are simply not found and stacks stay minified — which looks
809
+ identical to never having uploaded them. The function warns once per release when it
810
+ resolves nothing, naming the exact object it looked for, so the mismatch is visible in your
811
+ logs rather than inferred.
812
+
813
+ ## This is a hard problem
814
+
815
+ Not a difficult one — the pieces are all small. A hard one, in that the ways it goes wrong
816
+ are invisible until they aren't, and each is discovered by watching production do something
817
+ strange rather than by reading a doc.
818
+
819
+ Some of what is already handled here, all of it learned the expensive way:
820
+
821
+ - **Entry labels have to be emitted under `logging.googleapis.com/labels`.** Anywhere else
822
+ and they land inside the payload, so `labels.appId="…"` matches nothing. The logs look
823
+ perfect and cannot be filtered. Only a deployed run reveals it.
824
+ - **`AsyncLocalStorage.enterWith()` never unwinds.** A request's `userId` outlives the
825
+ request, and the next handler on a warm instance inherits whichever user came before.
826
+ - **Source maps left in `dist/` are your source code, published.** Uploading them is the
827
+ easy half; keeping them off the web server is the half people forget.
828
+ - **An unrecognised severity throws inside the write**, the entry is lost with no
829
+ diagnostic, and it slips past the severity floor on the way there.
830
+ - **A trailing slash on a Storage prefix is a different object.** `fsl//r7/app.js.map` is
831
+ not `fsl/r7/app.js.map`, and nothing collapses it — so the writer and reader silently
832
+ disagree over a typo.
833
+ - **Checking a rate limit and spending it as two calls double-counts**, quietly making a
834
+ configured budget of 50 a budget of 25.
835
+ - **An old stack naming a bundle that still exists** resolves against the current release's
836
+ map, giving line numbers that are confidently wrong — worse than none, because nothing
837
+ signals it.
838
+ - **`@google-cloud/logging` does not surface `errorGroups`.** Read grouping through the
839
+ client library and you will conclude, wrongly, that nothing grouped.
840
+
841
+ Every one of those is fixed here, and each has a test that fails if it comes back. That is
842
+ the point: you would have found them one at a time, in production, over months.
843
+
844
+ The list is not finished. It grows every time this is run against something real, and the
845
+ honest pitch is not that this package is complete — it plainly isn't — but that someone is
846
+ still walking into these and fixing them. Code you wrote yourself is frozen the day you
847
+ write it.
848
+
849
+ Decide for yourself whether that is worth a dependency.
850
+
851
+ ## What this is not
852
+
853
+ **A product of ours.** The grouping above is Google's Error Reporting, running in your
854
+ project — we make its input legible, we do not build or run it. If it changes, you are
855
+ downstream of that, the same as you already are for Cloud Logging.
856
+
857
+ **A triage tool with a console.** There is no assignment, no ownership, no dashboard of
858
+ ours. What exists is Google's console, plus whatever queries you write.
859
+
860
+ There are hosted error trackers that do all of that, and do it well. The trade is worth
861
+ stating plainly, because it is the whole reason to choose this instead.
862
+
863
+ **Nothing leaves your project.** Every entry, every breadcrumb, every screenshot stays in
864
+ the Google Cloud project you already own, under your own IAM and your own retention rules.
865
+ No third party receives it, no third party stores it, and there is no data-processing
866
+ agreement to negotiate because there is no processor.
867
+
868
+ That matters most where it usually matters: **breadcrumbs and attachments carry what a user
869
+ was actually doing**, and a screenshot of a checkout page is not something everyone is free
870
+ to hand to a vendor. If you are in health, finance, education, or anywhere a contract names
871
+ where data may live, that is not a preference — it is the decision.
872
+
873
+ What you give up is a polished product: no vendor UI, no onboarding flow, no support
874
+ contract, no assignment workflow. What you get is every frontend and backend event in one
875
+ stream you already own, in one query language, grouped by a console that came with the
876
+ project.
311
877
 
312
878
  ## License
313
879