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