From cc2f07a154ed7db0cc47369db77fd68a656c3e9b Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Sun, 6 Sep 2026 18:48:33 -0400 Subject: [PATCH 1/4] feat(observability): send metrics and logs to Grafana Cloud The lambdas have carried OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS since 2dacc6d, but nothing in the repo read them, so the Grafana account received nothing. Adds @branch/lambda-telemetry, which exports OTLP metrics and structured logs, and wires it into the two places every request already passes through: - dispatch() records latency, status, cold starts, auth refusals and unhandled errors for every route in all six services, emits one access log per request, and flushes before returning -- Lambda freezes the container on return, so an unflushed record is never seen. - each db.ts passes `log: kyselyTelemetryLog`, measuring every statement and logging the slow and failed ones. Controllers add domain metrics on top: login outcomes, invitations, project and expenditure changes, donation counts and amounts, report generation duration, size and outcome. Logs go to stdout as JSON (CloudWatch is unchanged) and to Loki, carrying the request id, route, status, duration and caller. serverError() routes through the same logger, so every 500 is structured. Constraints held throughout: telemetry never breaks a request (every recorder swallows its own failures, the flush is capped at 2s), no unbounded labels (route patterns not paths, statement kinds not SQL, no ids), delta temporality because Lambda containers are short-lived, and the SDK is never loaded at all when no endpoint is configured -- which is what keeps local dev and tests offline and quiet. No Terraform change: the env vars were already there. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 6 +- apps/backend/AGENTS.md | 7 +- apps/backend/lambdas/AGENTS.md | 3 +- apps/backend/lambdas/auth/controllers/auth.ts | 8 + .../lambdas/auth/controllers/register.ts | 3 + apps/backend/lambdas/auth/db.ts | 3 + apps/backend/lambdas/auth/package-lock.json | 27 + apps/backend/lambdas/auth/package.json | 1 + .../lambdas/donors/controllers/donations.ts | 6 + apps/backend/lambdas/donors/db.ts | 3 + apps/backend/lambdas/donors/package-lock.json | 27 + apps/backend/lambdas/donors/package.json | 1 + .../expenditures/controllers/expenditures.ts | 13 + apps/backend/lambdas/expenditures/db.ts | 3 + .../lambdas/expenditures/package-lock.json | 27 + .../backend/lambdas/expenditures/package.json | 1 + .../lambdas/projects/controllers/projects.ts | 6 + apps/backend/lambdas/projects/db.ts | 3 + .../lambdas/projects/package-lock.json | 27 + apps/backend/lambdas/projects/package.json | 1 + .../lambdas/reports/controllers/reports.ts | 13 + apps/backend/lambdas/reports/db.ts | 3 + .../backend/lambdas/reports/package-lock.json | 27 + apps/backend/lambdas/reports/package.json | 1 + .../lambdas/users/controllers/users.ts | 2 + apps/backend/lambdas/users/db.ts | 3 + apps/backend/lambdas/users/package-lock.json | 27 + apps/backend/lambdas/users/package.json | 1 + .../2026-09-06-grafana-telemetry-design.md | 119 + infrastructure/AGENTS.md | 2 +- shared/lambda-http/package-lock.json | 26 + shared/lambda-http/package.json | 1 + shared/lambda-http/src/dispatch.ts | 166 +- shared/lambda-http/src/errors.ts | 6 +- shared/lambda-http/test/telemetry.test.ts | 236 + shared/lambda-telemetry/README.md | 94 + shared/lambda-telemetry/jest.config.js | 5 + shared/lambda-telemetry/package-lock.json | 4919 +++++++++++++++++ shared/lambda-telemetry/package.json | 33 + shared/lambda-telemetry/src/config.ts | 81 + shared/lambda-telemetry/src/context.ts | 29 + shared/lambda-telemetry/src/db.ts | 43 + shared/lambda-telemetry/src/index.ts | 18 + shared/lambda-telemetry/src/logger.ts | 115 + shared/lambda-telemetry/src/metrics.ts | 161 + shared/lambda-telemetry/src/provider.ts | 150 + shared/lambda-telemetry/test/config.test.ts | 71 + shared/lambda-telemetry/test/db.test.ts | 78 + shared/lambda-telemetry/test/logger.test.ts | 103 + shared/lambda-telemetry/test/metrics.test.ts | 63 + shared/lambda-telemetry/test/provider.test.ts | 44 + shared/lambda-telemetry/tsconfig.json | 17 + 52 files changed, 6794 insertions(+), 39 deletions(-) create mode 100644 docs/superpowers/specs/2026-09-06-grafana-telemetry-design.md create mode 100644 shared/lambda-http/test/telemetry.test.ts create mode 100644 shared/lambda-telemetry/README.md create mode 100644 shared/lambda-telemetry/jest.config.js create mode 100644 shared/lambda-telemetry/package-lock.json create mode 100644 shared/lambda-telemetry/package.json create mode 100644 shared/lambda-telemetry/src/config.ts create mode 100644 shared/lambda-telemetry/src/context.ts create mode 100644 shared/lambda-telemetry/src/db.ts create mode 100644 shared/lambda-telemetry/src/index.ts create mode 100644 shared/lambda-telemetry/src/logger.ts create mode 100644 shared/lambda-telemetry/src/metrics.ts create mode 100644 shared/lambda-telemetry/src/provider.ts create mode 100644 shared/lambda-telemetry/test/config.test.ts create mode 100644 shared/lambda-telemetry/test/db.test.ts create mode 100644 shared/lambda-telemetry/test/logger.test.ts create mode 100644 shared/lambda-telemetry/test/metrics.test.ts create mode 100644 shared/lambda-telemetry/test/provider.test.ts create mode 100644 shared/lambda-telemetry/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index ad450a02..572685c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ BRANCH is a non-profit accounting platform (projects, donors, donations, expendi | `shared/rbac/` | `@branch/rbac` — **the** authorization policy, shared by lambdas + frontend | `shared/rbac/README.md` | | `shared/lambda-auth/` | `@branch/lambda-auth` — runtime Cognito auth/authz pkg | see backend doc | | `shared/lambda-http/` | `@branch/lambda-http` — route table, dispatch, permission enforcement | see backend doc | +| `shared/lambda-telemetry/` | `@branch/lambda-telemetry` — OTLP metrics + structured logs to Grafana Cloud | `shared/lambda-telemetry/README.md` | | `infrastructure/` | Terraform: `aws/`, `github/`, `preview/`, `preview-shared/`, `test/` | `infrastructure/AGENTS.md` | | `.github/workflows/` | CI/CD + PR review bot | `.github/AGENTS.md` | @@ -30,12 +31,13 @@ BRANCH is a non-profit accounting platform (projects, donors, donations, expendi ## Shared packages (critical) -Four `file:`-linked packages dedupe code across the repo: +Five `file:`-linked packages dedupe code across the repo: - **`@branch/types`** (`shared/types/`) — types only, no runtime. Exports DB row types (`DB`, `BranchUsers`, ...) + auth DTOs (`AuthContext`, `AuthenticatedUser`, `AccessLevel`, `AuthorizationCheck`). It is the **single declaration** of those DTOs: `@branch/lambda-auth` depends on this package and re-exports them, so never add a second copy anywhere. `db-types.d.ts` is **generated** from `apps/backend/db/migrations/**` by the `Schema Change Checks` workflow (or locally by `make types`) — never hand-edit it. - **`@branch/rbac`** (`shared/rbac/`) — the authorization policy, as one table of rules plus a pure `can`/`authorize`. **The lambdas and the frontend both evaluate this module**, so a disabled button and the 403 behind it cannot disagree. It is the only place a permission is defined; do not re-derive one from `isAdmin` in a component or a controller. Read `shared/rbac/README.md` — it carries the role matrix — before changing who may do what. - **`@branch/lambda-auth`** (`shared/lambda-auth/`) — runtime auth: `authenticateRequest(db, event)`, `extractToken(event)`, `loadRbacSubject(db, ctx)` (one query for the caller's memberships, which is also what `GET /auth/me` ships to the browser). Lambdas wrap it in their local `auth.ts`. -- **`@branch/lambda-http`** (`shared/lambda-http/`) — runtime routing: `dispatch(event, { prefix, routes, resolveAuth })` replaces the old per-lambda if-chain handler with a declarative `Route[]` table (`routes.ts`). Every route declares `access: 'public' | 'authenticated'` or a `permission`, and dispatch enforces it before the controller runs; the union has no default arm, so omitting the gate is a type error. Also exports `json`, `parseBody`, `requirePermission`, `createAuthResolver`. Depends on `@branch/rbac`'s and `@branch/lambda-auth`'s `dist/`, so build those first (`.github/actions/build-shared-packages` discovers `shared/*/` and builds in `@branch`-dep order). See `apps/backend/lambdas/AGENTS.md`. +- **`@branch/lambda-http`** (`shared/lambda-http/`) — runtime routing: `dispatch(event, { prefix, routes, resolveAuth })` replaces the old per-lambda if-chain handler with a declarative `Route[]` table (`routes.ts`). Every route declares `access: 'public' | 'authenticated'` or a `permission`, and dispatch enforces it before the controller runs; the union has no default arm, so omitting the gate is a type error. Also exports `json`, `parseBody`, `requirePermission`, `createAuthResolver`. Depends on `@branch/rbac`'s, `@branch/lambda-auth`'s and `@branch/lambda-telemetry`'s `dist/`, so build those first (`.github/actions/build-shared-packages` discovers `shared/*/` and builds in `@branch`-dep order). See `apps/backend/lambdas/AGENTS.md`. +- **`@branch/lambda-telemetry`** (`shared/lambda-telemetry/`) — dependency, runtime observability: OTLP metrics and structured logs to Grafana Cloud. `dispatch()` calls it, so **every route in every service already reports** latency, status, cold starts, auth refusals and query duration, and emits one access log per request; controllers only add domain metrics (`recordEvent(METRICS.LOGIN, …)`). It is the only consumer of the `OTEL_EXPORTER_OTLP_*` env vars Terraform sets. With no endpoint configured the OTel SDK is never loaded, so local dev and tests are unaffected. Read `shared/lambda-telemetry/README.md` before adding a metric — the label-cardinality rules there are not optional. ## Root commands diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index a8959e09..c6875620 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -52,10 +52,11 @@ http://localhost:3000//health ## Shared packages -All three linked via `file:` deps in each lambda's `package.json`: +All four linked via `file:` deps in each lambda's `package.json`: - `@branch/types` (`../../../../shared/types`) — devDependency, types only. Must stay a dependency-free leaf: `@branch/lambda-auth` depends on it. - `@branch/lambda-auth` (`../../../../shared/lambda-auth`) — dependency, runtime auth. Build it (`npm run build` in `shared/lambda-auth`) when its source changes; lambdas consume `dist/`. It depends on `@branch/types` and re-exports the auth DTOs from there, so those types have exactly one declaration; changing `shared/lambda-auth/package.json` deps invalidates every lambda's `package-lock.json`, so regenerate all six. -- `@branch/lambda-http` (`../../../../shared/lambda-http`) — dependency, runtime routing: `dispatch(event, { prefix, routes, resolveAuth })` plus `json`/`parseBody`/`requirePermission`/`createAuthResolver`. Every lambda's `handler.ts` is a 5-line call into it; each lambda's own `routes.ts` supplies the `Route[]` table, and every route in it declares a `permission` or an `access` level that dispatch enforces. Depends on `@branch/rbac`'s and `@branch/lambda-auth`'s `dist/`, so build those first. See `lambdas/AGENTS.md`. +- `@branch/lambda-http` (`../../../../shared/lambda-http`) — dependency, runtime routing: `dispatch(event, { prefix, routes, resolveAuth })` plus `json`/`parseBody`/`requirePermission`/`createAuthResolver`. Every lambda's `handler.ts` is a 5-line call into it; each lambda's own `routes.ts` supplies the `Route[]` table, and every route in it declares a `permission` or an `access` level that dispatch enforces. Depends on `@branch/rbac`'s, `@branch/lambda-auth`'s and `@branch/lambda-telemetry`'s `dist/`, so build those first. See `lambdas/AGENTS.md`. +- `@branch/lambda-telemetry` (`../../../../shared/lambda-telemetry`) — dependency, runtime metrics + structured logs over OTLP to Grafana Cloud. Wired into `dispatch()` (RED metrics, cold starts, auth refusals, access log) and into each `db.ts` via `log: kyselyTelemetryLog` (query duration, slow/failed query lines). Controllers add domain counters with `recordEvent`/`recordValue`. Inert unless `OTEL_EXPORTER_OTLP_ENDPOINT` is set, which is why local dev and tests need no collector. ## Deploy @@ -70,6 +71,8 @@ Automatic on push to `main` touching `apps/backend/lambdas/**` or `shared/types/ `DB_HOST DB_PORT DB_USER DB_PASSWORD DB_NAME`, `COGNITO_USER_POOL_ID`, `COGNITO_CLIENT_ID` (or `COGNITO_APP_CLIENT_ID`), `REPORTS_BUCKET_NAME` (reports, expenditures and projects — one bucket holds both the `reports/` and `receipts/` prefixes, and all three delete out of it), `AWS_REGION` (default `us-east-2`, Lambda-reserved — never set it in Terraform). +Observability adds `OTEL_EXPORTER_OTLP_ENDPOINT` / `OTEL_EXPORTER_OTLP_HEADERS` (read by `@branch/lambda-telemetry`; absent means no export at all, which is the local and test default) and the optional `LOG_LEVEL`. + **Anything a lambda reads from `process.env` must be declared in the `environment` block of `infrastructure/aws/lambda.tf`.** That block is authoritative and is deliberately not in `lifecycle.ignore_changes`, so a value set by hand in the console is deleted on the next apply. Locally the Cognito values must be the real shared dev-pool IDs (`apps/backend/.env`) — auth talks to the real pool for JWKS and `InitiateAuth` — but no AWS credentials are needed, because every Cognito API on the sign-in path is unsigned. ## Auth diff --git a/apps/backend/lambdas/AGENTS.md b/apps/backend/lambdas/AGENTS.md index ecbcc731..9e63ccad 100644 --- a/apps/backend/lambdas/AGENTS.md +++ b/apps/backend/lambdas/AGENTS.md @@ -51,6 +51,7 @@ export const getDonor: RouteHandler = async ({ params }) => { }; ``` - `dispatch()` handles OPTIONS preflight, `GET //health`, 404 and 500 centrally — routes.ts only lists real endpoints. +- **`dispatch()` is also where the backend is instrumented.** Latency, status, cold starts and auth refusals are recorded there for every route, and one `Request served` log line carries the request id, route, status, duration and caller. Controllers add only *domain* metrics — `recordEvent(METRICS.EXPENDITURE_CHANGED, { action: 'reviewed', status })` — never a per-request counter of their own, which would double-count. Route *patterns* are the label; a concrete path or a row id never is. See `shared/lambda-telemetry/README.md`. - **A caught error is an error Sentry cannot see.** The Sentry layer wraps the handler and records uncaught throws only, and we always catch so API Gateway returns a JSON 500 instead of a 502. `dispatch()` reports what reaches its catch; a controller that returns its own 500 must use `serverError(err, 'Failed to …')` from `@branch/lambda-http` rather than `console.error` + `json(500, …)`. Same status to the caller, one less invisible failure. - **NEVER remove or modify the `ROUTES-START` / `ROUTES-END` markers** — the CLI inserts new table entries between them. - Patterns are always full-prefixed (`/donors/:id`, never `/:id`) so one table works whether the event arrives via API Gateway's `{proxy+}` (full path) or the shared dev-server (prefix stripped) — `dispatch()` canonicalizes both to the prefixed form. @@ -116,7 +117,7 @@ the same predicate** or the total leaks what the page does not. ## DB access -`db.ts` exports a `Kysely` (`DB` from `@branch/types`) over a `pg.Pool`. Always qualify the schema: +`db.ts` exports a `Kysely` (`DB` from `@branch/types`) over a `pg.Pool`, with `log: kyselyTelemetryLog` so every statement's duration is measured and anything slow or failed gets a log line. A new lambda's `db.ts` must keep that hook. Always qualify the schema: ```ts await db.selectFrom('branch.users').where('cognito_sub', '=', sub).selectAll().executeTakeFirst(); await db.selectFrom('branch.users').select(db.fn.count('user_id').as('count')).executeTakeFirst(); diff --git a/apps/backend/lambdas/auth/controllers/auth.ts b/apps/backend/lambdas/auth/controllers/auth.ts index cd6267c3..b937bf9d 100644 --- a/apps/backend/lambdas/auth/controllers/auth.ts +++ b/apps/backend/lambdas/auth/controllers/auth.ts @@ -9,6 +9,7 @@ import { } from '@aws-sdk/client-cognito-identity-provider'; import { json, parseBody, reportError, serverError } from '@branch/lambda-http'; import type { RouteHandler } from '@branch/lambda-http'; +import { METRICS, recordEvent } from '@branch/lambda-telemetry'; import { resolveProfileImage } from '../photos'; import { cognitoClient, @@ -58,10 +59,14 @@ export async function handleLogin(event: any): Promise { const response = await cognitoClient.send(new InitiateAuthCommand(params)); if (response.AuthenticationResult) { + recordEvent(METRICS.LOGIN, { outcome: 'success' }); return authResultResponse(response.AuthenticationResult); } if (response.ChallengeName) { + // The challenge name is a small fixed set, so it is safe as a label. + recordEvent(METRICS.LOGIN, { outcome: 'challenge', challenge: response.ChallengeName }); + // MFA_SETUP cannot be answered by RespondToAuthChallenge alone -- it needs // AssociateSoftwareToken/VerifySoftwareToken enrollment, which is not // built yet. Return the Session anyway so a future enrollment endpoint can @@ -81,6 +86,9 @@ export async function handleLogin(event: any): Promise { 'Unexpected response from authentication service', ); } catch (error: any) { + // A spike here is the credential-stuffing signal. The address is + // deliberately not a label: it is PII and unbounded cardinality. + recordEvent(METRICS.LOGIN, { outcome: 'failure', 'error.type': error?.name ?? 'Unknown' }); return mapCognitoAuthError(error, 'login'); } } diff --git a/apps/backend/lambdas/auth/controllers/register.ts b/apps/backend/lambdas/auth/controllers/register.ts index 5b01d7fc..6fa2177c 100644 --- a/apps/backend/lambdas/auth/controllers/register.ts +++ b/apps/backend/lambdas/auth/controllers/register.ts @@ -9,6 +9,7 @@ import { ResendConfirmationCodeCommand, } from '@aws-sdk/client-cognito-identity-provider'; import { json, reportError, serverError } from '@branch/lambda-http'; +import { METRICS, recordEvent } from '@branch/lambda-telemetry'; import db from '../db'; import { cognitoClient, USER_POOL_CLIENT_ID, USER_POOL_ID, validatePassword } from '../services/cognito'; @@ -71,6 +72,7 @@ export async function handleRegister(event: any): Promise // email addresses have been invited, so the response is deliberately the // same whether or not the address is known. if (!existingUser) { + recordEvent(METRICS.REGISTRATION, { outcome: 'invitation_required' }); return json(403, { message: 'Registration is by invitation only. Ask an administrator to create your account.', @@ -214,6 +216,7 @@ export async function handleRegister(event: any): Promise return json(500, { message: 'Failed to create user account' }); } + recordEvent(METRICS.REGISTRATION, { outcome: 'claimed' }); return json(201, { message: 'User registered successfully', userId: cognitoUserSub, diff --git a/apps/backend/lambdas/auth/db.ts b/apps/backend/lambdas/auth/db.ts index 6a890925..423e9dcb 100644 --- a/apps/backend/lambdas/auth/db.ts +++ b/apps/backend/lambdas/auth/db.ts @@ -2,8 +2,11 @@ import { Kysely, PostgresDialect } from 'kysely' import { Pool } from 'pg' import type { DB } from '@branch/types' +import { kyselyTelemetryLog } from '@branch/lambda-telemetry' const db = new Kysely({ + // Query duration + slow/failed query logs. See @branch/lambda-telemetry. + log: kyselyTelemetryLog, dialect: new PostgresDialect({ pool: new Pool({ host: process.env.DB_HOST ?? 'localhost', diff --git a/apps/backend/lambdas/auth/package-lock.json b/apps/backend/lambdas/auth/package-lock.json index db245921..2f91824b 100644 --- a/apps/backend/lambdas/auth/package-lock.json +++ b/apps/backend/lambdas/auth/package-lock.json @@ -13,6 +13,7 @@ "@aws-sdk/s3-request-presigner": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@branch/lambda-http": "file:../../../../shared/lambda-http", + "@branch/lambda-telemetry": "file:../../../../shared/lambda-telemetry", "@branch/rbac": "file:../../../../shared/rbac", "aws-jwt-verify": "^5.1.1", "dotenv": "^17.2.3", @@ -57,6 +58,7 @@ "version": "1.0.0", "dependencies": { "@branch/lambda-auth": "file:../lambda-auth", + "@branch/lambda-telemetry": "file:../lambda-telemetry", "@branch/rbac": "file:../rbac" }, "devDependencies": { @@ -69,6 +71,27 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-telemetry": { + "name": "@branch/lambda-telemetry", + "version": "1.0.0", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/api-logs": "^0.222.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.222.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.222.0", + "@opentelemetry/resources": "^2.11.0", + "@opentelemetry/sdk-logs": "^0.222.0", + "@opentelemetry/sdk-metrics": "^2.11.0" + }, + "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, "../../../../shared/rbac": { "name": "@branch/rbac", "version": "1.0.0", @@ -1255,6 +1278,10 @@ "resolved": "../../../../shared/lambda-http", "link": true }, + "node_modules/@branch/lambda-telemetry": { + "resolved": "../../../../shared/lambda-telemetry", + "link": true + }, "node_modules/@branch/rbac": { "resolved": "../../../../shared/rbac", "link": true diff --git a/apps/backend/lambdas/auth/package.json b/apps/backend/lambdas/auth/package.json index 9e2c63e2..08acf136 100644 --- a/apps/backend/lambdas/auth/package.json +++ b/apps/backend/lambdas/auth/package.json @@ -30,6 +30,7 @@ "@aws-sdk/s3-request-presigner": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@branch/lambda-http": "file:../../../../shared/lambda-http", + "@branch/lambda-telemetry": "file:../../../../shared/lambda-telemetry", "@branch/rbac": "file:../../../../shared/rbac", "aws-jwt-verify": "^5.1.1", "dotenv": "^17.2.3", diff --git a/apps/backend/lambdas/donors/controllers/donations.ts b/apps/backend/lambdas/donors/controllers/donations.ts index 2d082cee..3b583045 100644 --- a/apps/backend/lambdas/donors/controllers/donations.ts +++ b/apps/backend/lambdas/donors/controllers/donations.ts @@ -1,6 +1,7 @@ import type { RouteCtx } from '@branch/lambda-http'; import { json, requirePermission } from '@branch/lambda-http'; import { projectScopeIds } from '@branch/rbac'; +import { METRICS, recordEvent, recordValue } from '@branch/lambda-telemetry'; import { sql, type SqlBool } from 'kysely'; import db from '../db'; @@ -143,6 +144,11 @@ export async function createDonation({ event }: RouteCtx) { .returningAll() .executeTakeFirstOrThrow(); + // Donation rate and size. Ids stay out of the labels — one series per donor + // would be unbounded, and the rollups already answer per-project questions. + recordEvent(METRICS.DONATION_RECORDED, { backdated: donatedAt !== undefined }); + recordValue(METRICS.DONATION_AMOUNT, donationAmount); + return json(201, { data: donation }); } catch (err: any) { if (err?.code === '23505') { diff --git a/apps/backend/lambdas/donors/db.ts b/apps/backend/lambdas/donors/db.ts index 655b36f0..852f5bdf 100644 --- a/apps/backend/lambdas/donors/db.ts +++ b/apps/backend/lambdas/donors/db.ts @@ -1,9 +1,12 @@ import { Kysely, PostgresDialect } from 'kysely' import { Pool } from 'pg' import type { DB } from '@branch/types' +import { kyselyTelemetryLog } from '@branch/lambda-telemetry' const db = new Kysely({ + // Query duration + slow/failed query logs. See @branch/lambda-telemetry. + log: kyselyTelemetryLog, dialect: new PostgresDialect({ pool: new Pool({ host: process.env.DB_HOST ?? 'localhost', diff --git a/apps/backend/lambdas/donors/package-lock.json b/apps/backend/lambdas/donors/package-lock.json index 4f58115e..25c785f9 100644 --- a/apps/backend/lambdas/donors/package-lock.json +++ b/apps/backend/lambdas/donors/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@branch/lambda-http": "file:../../../../shared/lambda-http", + "@branch/lambda-telemetry": "file:../../../../shared/lambda-telemetry", "@branch/rbac": "file:../../../../shared/rbac", "aws-jwt-verify": "^5.1.1", "kysely": "^0.28.8", @@ -51,6 +52,7 @@ "version": "1.0.0", "dependencies": { "@branch/lambda-auth": "file:../lambda-auth", + "@branch/lambda-telemetry": "file:../lambda-telemetry", "@branch/rbac": "file:../rbac" }, "devDependencies": { @@ -63,6 +65,27 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-telemetry": { + "name": "@branch/lambda-telemetry", + "version": "1.0.0", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/api-logs": "^0.222.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.222.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.222.0", + "@opentelemetry/resources": "^2.11.0", + "@opentelemetry/sdk-logs": "^0.222.0", + "@opentelemetry/sdk-metrics": "^2.11.0" + }, + "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, "../../../../shared/rbac": { "name": "@branch/rbac", "version": "1.0.0", @@ -584,6 +607,10 @@ "resolved": "../../../../shared/lambda-http", "link": true }, + "node_modules/@branch/lambda-telemetry": { + "resolved": "../../../../shared/lambda-telemetry", + "link": true + }, "node_modules/@branch/rbac": { "resolved": "../../../../shared/rbac", "link": true diff --git a/apps/backend/lambdas/donors/package.json b/apps/backend/lambdas/donors/package.json index 7879e97d..9a9d0dd5 100644 --- a/apps/backend/lambdas/donors/package.json +++ b/apps/backend/lambdas/donors/package.json @@ -25,6 +25,7 @@ "dependencies": { "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@branch/lambda-http": "file:../../../../shared/lambda-http", + "@branch/lambda-telemetry": "file:../../../../shared/lambda-telemetry", "@branch/rbac": "file:../../../../shared/rbac", "aws-jwt-verify": "^5.1.1", "kysely": "^0.28.8", diff --git a/apps/backend/lambdas/expenditures/controllers/expenditures.ts b/apps/backend/lambdas/expenditures/controllers/expenditures.ts index 78e33068..5f6e5bdf 100644 --- a/apps/backend/lambdas/expenditures/controllers/expenditures.ts +++ b/apps/backend/lambdas/expenditures/controllers/expenditures.ts @@ -2,6 +2,7 @@ import type { RouteHandler } from '@branch/lambda-http'; import { json, requirePermission, serverError } from '@branch/lambda-http'; import { can } from '@branch/rbac'; import type { ExpenseResource } from '@branch/rbac'; +import { METRICS, recordEvent, recordValue } from '@branch/lambda-telemetry'; import { ExpenditureValidationUtils } from '../validation-utils'; import * as expendituresService from '../services/expenditures'; import { expenditureScope } from '../services/scope'; @@ -129,6 +130,15 @@ export const createExpenditure: RouteHandler = async ({ event, auth }) => { return serverError(err, 'Failed to create expenditure'); } + // `category` is a small controlled vocabulary; `projectID` deliberately is not + // a label, since it grows without bound. + recordEvent(METRICS.EXPENDITURE_CHANGED, { + action: 'created', + status: effectiveStatus, + category: category ?? 'uncategorised', + }); + recordValue(METRICS.EXPENDITURE_AMOUNT, Number(amount), { category: category ?? 'uncategorised' }); + return json(201, { ok: true, route: 'POST /expenditures', @@ -357,6 +367,9 @@ export const patchExpenditureStatus: RouteHandler = async ({ event, params }) => return json(404, { message: 'Expenditure not found' }); } + // The approval funnel: rate of approved vs denied vs needs_more_info. + recordEvent(METRICS.EXPENDITURE_CHANGED, { action: 'reviewed', status: updated.status }); + // Email on approve/denial — best-effort, never blocks the response. if (updated.entered_by && (updated.status === 'approved' || updated.status === 'denied')) { const submitter = await expendituresService.getUserContact(updated.entered_by); diff --git a/apps/backend/lambdas/expenditures/db.ts b/apps/backend/lambdas/expenditures/db.ts index 773138d3..af7764f6 100644 --- a/apps/backend/lambdas/expenditures/db.ts +++ b/apps/backend/lambdas/expenditures/db.ts @@ -1,9 +1,12 @@ import { Kysely, PostgresDialect } from 'kysely' import { Pool } from 'pg' import type { DB } from '@branch/types' +import { kyselyTelemetryLog } from '@branch/lambda-telemetry' const db = new Kysely({ + // Query duration + slow/failed query logs. See @branch/lambda-telemetry. + log: kyselyTelemetryLog, dialect: new PostgresDialect({ pool: new Pool({ host: process.env.DB_HOST ?? 'localhost', diff --git a/apps/backend/lambdas/expenditures/package-lock.json b/apps/backend/lambdas/expenditures/package-lock.json index bce77b8f..d5a12e92 100644 --- a/apps/backend/lambdas/expenditures/package-lock.json +++ b/apps/backend/lambdas/expenditures/package-lock.json @@ -13,6 +13,7 @@ "@aws-sdk/s3-request-presigner": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@branch/lambda-http": "file:../../../../shared/lambda-http", + "@branch/lambda-telemetry": "file:../../../../shared/lambda-telemetry", "@branch/rbac": "file:../../../../shared/rbac", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", @@ -57,6 +58,7 @@ "version": "1.0.0", "dependencies": { "@branch/lambda-auth": "file:../lambda-auth", + "@branch/lambda-telemetry": "file:../lambda-telemetry", "@branch/rbac": "file:../rbac" }, "devDependencies": { @@ -69,6 +71,27 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-telemetry": { + "name": "@branch/lambda-telemetry", + "version": "1.0.0", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/api-logs": "^0.222.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.222.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.222.0", + "@opentelemetry/resources": "^2.11.0", + "@opentelemetry/sdk-logs": "^0.222.0", + "@opentelemetry/sdk-metrics": "^2.11.0" + }, + "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, "../../../../shared/rbac": { "name": "@branch/rbac", "version": "1.0.0", @@ -934,6 +957,10 @@ "resolved": "../../../../shared/lambda-http", "link": true }, + "node_modules/@branch/lambda-telemetry": { + "resolved": "../../../../shared/lambda-telemetry", + "link": true + }, "node_modules/@branch/rbac": { "resolved": "../../../../shared/rbac", "link": true diff --git a/apps/backend/lambdas/expenditures/package.json b/apps/backend/lambdas/expenditures/package.json index 8ebdfdfd..55c28974 100644 --- a/apps/backend/lambdas/expenditures/package.json +++ b/apps/backend/lambdas/expenditures/package.json @@ -31,6 +31,7 @@ "@aws-sdk/s3-request-presigner": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@branch/lambda-http": "file:../../../../shared/lambda-http", + "@branch/lambda-telemetry": "file:../../../../shared/lambda-telemetry", "@branch/rbac": "file:../../../../shared/rbac", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", diff --git a/apps/backend/lambdas/projects/controllers/projects.ts b/apps/backend/lambdas/projects/controllers/projects.ts index ceee9836..e03c033a 100644 --- a/apps/backend/lambdas/projects/controllers/projects.ts +++ b/apps/backend/lambdas/projects/controllers/projects.ts @@ -1,5 +1,6 @@ import { json, parseBody, requirePermission, RouteHandler, serverError } from '@branch/lambda-http'; import { projectScopeIds } from '@branch/rbac'; +import { METRICS, recordEvent } from '@branch/lambda-telemetry'; import { sql, type SqlBool } from 'kysely'; import db from '../db'; import { ProjectValidationUtils } from '../validation-utils'; @@ -177,6 +178,10 @@ export const deleteProject: RouteHandler = async ({ params }) => { // which is the single largest source of unreferenced objects. const filesDeleted = await deleteProjectObjects(Number(id)); + // A cascade delete takes expenditures, reports and their S3 objects with it, + // so this is worth being able to correlate against on a dashboard. + recordEvent(METRICS.PROJECT_CHANGED, { action: 'deleted' }); + return json(200, { ok: true, route: 'DELETE /projects/{projectId}', pathParams: { id }, filesDeleted }); }; @@ -251,6 +256,7 @@ export const createProject: RouteHandler = async ({ event, auth }) => { return row; }); + recordEvent(METRICS.PROJECT_CHANGED, { action: 'created', staffed: members.length > 0 }); return json(201, inserted); } catch (e) { return serverError(e, 'Failed to create project'); diff --git a/apps/backend/lambdas/projects/db.ts b/apps/backend/lambdas/projects/db.ts index 284bf483..e1c400ce 100644 --- a/apps/backend/lambdas/projects/db.ts +++ b/apps/backend/lambdas/projects/db.ts @@ -1,8 +1,11 @@ import { Kysely, PostgresDialect } from 'kysely' import { Pool } from 'pg' import type { DB } from '@branch/types' +import { kyselyTelemetryLog } from '@branch/lambda-telemetry' const db = new Kysely({ + // Query duration + slow/failed query logs. See @branch/lambda-telemetry. + log: kyselyTelemetryLog, dialect: new PostgresDialect({ pool: new Pool({ host: process.env.DB_HOST ?? 'localhost', diff --git a/apps/backend/lambdas/projects/package-lock.json b/apps/backend/lambdas/projects/package-lock.json index e2d5a018..f99d4433 100644 --- a/apps/backend/lambdas/projects/package-lock.json +++ b/apps/backend/lambdas/projects/package-lock.json @@ -11,6 +11,7 @@ "@aws-sdk/client-s3": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@branch/lambda-http": "file:../../../../shared/lambda-http", + "@branch/lambda-telemetry": "file:../../../../shared/lambda-telemetry", "@branch/rbac": "file:../../../../shared/rbac", "aws-jwt-verify": "^5.1.1", "kysely": "^0.28.8", @@ -53,6 +54,7 @@ "version": "1.0.0", "dependencies": { "@branch/lambda-auth": "file:../lambda-auth", + "@branch/lambda-telemetry": "file:../lambda-telemetry", "@branch/rbac": "file:../rbac" }, "devDependencies": { @@ -65,6 +67,27 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-telemetry": { + "name": "@branch/lambda-telemetry", + "version": "1.0.0", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/api-logs": "^0.222.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.222.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.222.0", + "@opentelemetry/resources": "^2.11.0", + "@opentelemetry/sdk-logs": "^0.222.0", + "@opentelemetry/sdk-metrics": "^2.11.0" + }, + "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, "../../../../shared/rbac": { "name": "@branch/rbac", "version": "1.0.0", @@ -894,6 +917,10 @@ "resolved": "../../../../shared/lambda-http", "link": true }, + "node_modules/@branch/lambda-telemetry": { + "resolved": "../../../../shared/lambda-telemetry", + "link": true + }, "node_modules/@branch/rbac": { "resolved": "../../../../shared/rbac", "link": true diff --git a/apps/backend/lambdas/projects/package.json b/apps/backend/lambdas/projects/package.json index 276ba98b..f6a2abc3 100644 --- a/apps/backend/lambdas/projects/package.json +++ b/apps/backend/lambdas/projects/package.json @@ -27,6 +27,7 @@ "@aws-sdk/client-s3": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@branch/lambda-http": "file:../../../../shared/lambda-http", + "@branch/lambda-telemetry": "file:../../../../shared/lambda-telemetry", "@branch/rbac": "file:../../../../shared/rbac", "aws-jwt-verify": "^5.1.1", "kysely": "^0.28.8", diff --git a/apps/backend/lambdas/reports/controllers/reports.ts b/apps/backend/lambdas/reports/controllers/reports.ts index 72d49d56..4264be46 100644 --- a/apps/backend/lambdas/reports/controllers/reports.ts +++ b/apps/backend/lambdas/reports/controllers/reports.ts @@ -2,6 +2,7 @@ import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } fro import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { json, parseBody, reportError, serverError } from '@branch/lambda-http'; import type { RouteHandler } from '@branch/lambda-http'; +import { METRICS, recordEvent, recordValue } from '@branch/lambda-telemetry'; import db from '../db'; import { fetchReportData, @@ -99,23 +100,35 @@ export const generateReport: RouteHandler = async ({ event }) => { return json(404, { message: 'Project not found' }); } + // Rendering a PDF/DOCX is the slowest thing this backend does and the most + // likely to hit the 30s lambda timeout, so it is timed separately from the + // request as a whole. + const renderStartedAt = Date.now(); + const kind = { report_type: reportType, file_type: fileType }; + let fileBuffer: Buffer; try { fileBuffer = fileType === 'docx' ? await generateDocx(reportData) : await generatePdf(reportData); } catch (err) { + recordEvent(METRICS.REPORT_GENERATED, { ...kind, outcome: 'render_failed' }); return serverError(err, 'Failed to generate report'); } + recordValue(METRICS.REPORT_DURATION, Date.now() - renderStartedAt, kind); let objectUrl: string; try { objectUrl = await uploadToS3(fileBuffer, projectId, fileType); } catch (err) { + recordEvent(METRICS.REPORT_GENERATED, { ...kind, outcome: 'upload_failed' }); return serverError(err, 'Failed to upload report'); } const title = customTitle || `${reportData.project.name} — ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}`; const record = await saveReportRecord(projectId, objectUrl, title, reportType); + recordEvent(METRICS.REPORT_GENERATED, { ...kind, outcome: 'success' }); + recordValue(METRICS.REPORT_SIZE, fileBuffer.byteLength, kind); + return json(201, { ok: true, report_id: record.report_id, diff --git a/apps/backend/lambdas/reports/db.ts b/apps/backend/lambdas/reports/db.ts index 25eeac6e..b30a3a47 100644 --- a/apps/backend/lambdas/reports/db.ts +++ b/apps/backend/lambdas/reports/db.ts @@ -1,8 +1,11 @@ import { Kysely, PostgresDialect } from 'kysely' import { Pool } from 'pg' import type { DB } from '@branch/types' +import { kyselyTelemetryLog } from '@branch/lambda-telemetry' const db = new Kysely({ + // Query duration + slow/failed query logs. See @branch/lambda-telemetry. + log: kyselyTelemetryLog, dialect: new PostgresDialect({ pool: new Pool({ host: process.env.DB_HOST ?? 'localhost', diff --git a/apps/backend/lambdas/reports/package-lock.json b/apps/backend/lambdas/reports/package-lock.json index dbc70881..1741f39d 100644 --- a/apps/backend/lambdas/reports/package-lock.json +++ b/apps/backend/lambdas/reports/package-lock.json @@ -12,6 +12,7 @@ "@aws-sdk/s3-request-presigner": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@branch/lambda-http": "file:../../../../shared/lambda-http", + "@branch/lambda-telemetry": "file:../../../../shared/lambda-telemetry", "@branch/rbac": "file:../../../../shared/rbac", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", @@ -60,6 +61,7 @@ "version": "1.0.0", "dependencies": { "@branch/lambda-auth": "file:../lambda-auth", + "@branch/lambda-telemetry": "file:../lambda-telemetry", "@branch/rbac": "file:../rbac" }, "devDependencies": { @@ -72,6 +74,27 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-telemetry": { + "name": "@branch/lambda-telemetry", + "version": "1.0.0", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/api-logs": "^0.222.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.222.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.222.0", + "@opentelemetry/resources": "^2.11.0", + "@opentelemetry/sdk-logs": "^0.222.0", + "@opentelemetry/sdk-metrics": "^2.11.0" + }, + "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, "../../../../shared/rbac": { "name": "@branch/rbac", "version": "1.0.0", @@ -1494,6 +1517,10 @@ "resolved": "../../../../shared/lambda-http", "link": true }, + "node_modules/@branch/lambda-telemetry": { + "resolved": "../../../../shared/lambda-telemetry", + "link": true + }, "node_modules/@branch/rbac": { "resolved": "../../../../shared/rbac", "link": true diff --git a/apps/backend/lambdas/reports/package.json b/apps/backend/lambdas/reports/package.json index 8e016ad4..dc4a9231 100644 --- a/apps/backend/lambdas/reports/package.json +++ b/apps/backend/lambdas/reports/package.json @@ -31,6 +31,7 @@ "@aws-sdk/s3-request-presigner": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@branch/lambda-http": "file:../../../../shared/lambda-http", + "@branch/lambda-telemetry": "file:../../../../shared/lambda-telemetry", "@branch/rbac": "file:../../../../shared/rbac", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", diff --git a/apps/backend/lambdas/users/controllers/users.ts b/apps/backend/lambdas/users/controllers/users.ts index 703d771e..e3d160da 100644 --- a/apps/backend/lambdas/users/controllers/users.ts +++ b/apps/backend/lambdas/users/controllers/users.ts @@ -4,6 +4,7 @@ import { AdminDeleteUserCommand, } from '@aws-sdk/client-cognito-identity-provider'; import { json, reportError, requirePermission, type RouteHandler } from '@branch/lambda-http'; +import { METRICS, recordEvent } from '@branch/lambda-telemetry'; import db from '../db'; import { UserValidationUtils } from '../validation-utils'; import { @@ -338,6 +339,7 @@ export const createUser: RouteHandler = async ({ event }) => { return json(500, { message: 'Failed to create user' }); } + recordEvent(METRICS.USER_INVITED, { admin: isAdmin }); return json(201, { ok: true, route: 'POST /users', diff --git a/apps/backend/lambdas/users/db.ts b/apps/backend/lambdas/users/db.ts index 6a890925..423e9dcb 100644 --- a/apps/backend/lambdas/users/db.ts +++ b/apps/backend/lambdas/users/db.ts @@ -2,8 +2,11 @@ import { Kysely, PostgresDialect } from 'kysely' import { Pool } from 'pg' import type { DB } from '@branch/types' +import { kyselyTelemetryLog } from '@branch/lambda-telemetry' const db = new Kysely({ + // Query duration + slow/failed query logs. See @branch/lambda-telemetry. + log: kyselyTelemetryLog, dialect: new PostgresDialect({ pool: new Pool({ host: process.env.DB_HOST ?? 'localhost', diff --git a/apps/backend/lambdas/users/package-lock.json b/apps/backend/lambdas/users/package-lock.json index 91eb2d10..feffa39d 100644 --- a/apps/backend/lambdas/users/package-lock.json +++ b/apps/backend/lambdas/users/package-lock.json @@ -13,6 +13,7 @@ "@aws-sdk/s3-request-presigner": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@branch/lambda-http": "file:../../../../shared/lambda-http", + "@branch/lambda-telemetry": "file:../../../../shared/lambda-telemetry", "@branch/rbac": "file:../../../../shared/rbac", "aws-jwt-verify": "^5.1.1", "kysely": "^0.28.8", @@ -55,6 +56,7 @@ "version": "1.0.0", "dependencies": { "@branch/lambda-auth": "file:../lambda-auth", + "@branch/lambda-telemetry": "file:../lambda-telemetry", "@branch/rbac": "file:../rbac" }, "devDependencies": { @@ -67,6 +69,27 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-telemetry": { + "name": "@branch/lambda-telemetry", + "version": "1.0.0", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/api-logs": "^0.222.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.222.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.222.0", + "@opentelemetry/resources": "^2.11.0", + "@opentelemetry/sdk-logs": "^0.222.0", + "@opentelemetry/sdk-metrics": "^2.11.0" + }, + "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, "../../../../shared/rbac": { "name": "@branch/rbac", "version": "1.0.0", @@ -932,6 +955,10 @@ "resolved": "../../../../shared/lambda-http", "link": true }, + "node_modules/@branch/lambda-telemetry": { + "resolved": "../../../../shared/lambda-telemetry", + "link": true + }, "node_modules/@branch/rbac": { "resolved": "../../../../shared/rbac", "link": true diff --git a/apps/backend/lambdas/users/package.json b/apps/backend/lambdas/users/package.json index 297fab61..e11e7f08 100644 --- a/apps/backend/lambdas/users/package.json +++ b/apps/backend/lambdas/users/package.json @@ -29,6 +29,7 @@ "@aws-sdk/s3-request-presigner": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@branch/lambda-http": "file:../../../../shared/lambda-http", + "@branch/lambda-telemetry": "file:../../../../shared/lambda-telemetry", "@branch/rbac": "file:../../../../shared/rbac", "aws-jwt-verify": "^5.1.1", "kysely": "^0.28.8", diff --git a/docs/superpowers/specs/2026-09-06-grafana-telemetry-design.md b/docs/superpowers/specs/2026-09-06-grafana-telemetry-design.md new file mode 100644 index 00000000..9fba5c0f --- /dev/null +++ b/docs/superpowers/specs/2026-09-06-grafana-telemetry-design.md @@ -0,0 +1,119 @@ +# Sending metrics and logs to Grafana Cloud + +**Date:** 2026-09-06 +**Status:** implemented on `worktree-grafana-otel-telemetry` + +## Problem + +Grafana Cloud is provisioned and the lambdas have carried +`OTEL_EXPORTER_OTLP_ENDPOINT` and `OTEL_EXPORTER_OTLP_HEADERS` since commit +`2dacc6d`, but no code in the repo ever read them. The account receives nothing. + +What observability exists today: Sentry (uncaught throws, via a Lambda layer) +and unstructured `console.*` in CloudWatch with 30-day retention. There are no +CloudWatch alarms, no dashboards, no X-Ray. So there is no way to answer "is the +API slow", "which endpoint is erroring", "how long does a report take", or "did +that failed login spike matter". + +## Decisions + +Two choices drove the design. Both were taken as the recommended defaults; +they are recorded here as assumptions rather than as a user ruling. + +1. **Bundled OTel SDK in a shared package**, not an ADOT/OTel Lambda layer and + not CloudWatch EMF. It uses the OTLP endpoint that is already configured and + paid for, needs **no Terraform change at all**, and gives full control over + domain metrics. The layer route would have meant a Terraform change plus a + collector config and ~1s of extra cold start; EMF would have ignored the OTLP + endpoint and required a Grafana→AWS IAM role. + +2. **Metrics and structured logs, not traces.** Sentry already covers exception + detail, and traces would roughly double the bundle for overlapping value. + Traces remain easy to add later — the provider module is the only place that + would change. + +## Architecture + +One new `file:`-linked package, `@branch/lambda-telemetry`, plus instrumentation +at the two places every request already passes through. + +``` +shared/lambda-telemetry/ + config.ts env parsing; returns null when there is no endpoint + provider.ts lazy OTel MeterProvider + LoggerProvider, forceFlush + metrics.ts instrument registry + recorders; the METRICS name table + logger.ts structured logger -> stdout JSON *and* an OTel log record + context.ts AsyncLocalStorage request context + db.ts the Kysely `log` hook +``` + +**`dispatch()` is the choke point.** All six services route through it, so +instrumenting it once covers every endpoint: duration histogram, request +counter, cold starts, auth refusals (401 vs 403), unhandled errors, and one +access log per request. Controllers add only domain metrics. + +**`db.ts` is the second choke point.** `log: kyselyTelemetryLog` on each +lambda's Kysely instance measures every statement and logs the slow and failed +ones. + +### Signals + +Automatic, every route: + +| Metric | Kind | Labels | +|---|---|---| +| `http.server.requests` | counter | method, route, status | +| `http.server.request.duration` | histogram (ms) | method, route, status | +| `faas.cold_starts` | counter | — | +| `http.server.auth_failures` | counter | method, route, reason | +| `http.server.unhandled_errors` | counter | method, route | +| `db.client.operation.duration` | histogram (ms) | statement kind, outcome | + +Domain, recorded explicitly: + +| Metric | Where | Why it earns its place | +|---|---|---| +| `branch.auth.logins` | auth | A failure spike is the credential-stuffing signal | +| `branch.auth.registrations` | auth | `invitation_required` shows people bouncing off the invite gate | +| `branch.users.invited` | users | Onboarding throughput | +| `branch.projects.changed` | projects | Deletes cascade into expenditures, reports and S3 | +| `branch.expenditures.changed` | expenditures | The approval funnel: approved / denied / needs_more_info | +| `branch.expenditures.amount` | expenditures | Spend distribution by category | +| `branch.donations.recorded` / `.amount` | donors | Donation rate and size — the number the org actually cares about | +| `branch.reports.generated` | reports | Success vs render-failed vs upload-failed | +| `branch.reports.generation.duration` / `.size` | reports | The slowest path in the backend, and the one nearest the 30s timeout | + +Logs: one JSON line per `logger.*` call, to stdout **and** to Loki, carrying +request id, service, route, method, path, cold-start flag, user id, plus the +call's own fields. `serverError()` routes through it, so every 500 is +structured. An `Error` is flattened rather than serialised to `{}`. + +## Constraints honoured + +- **Telemetry never breaks a request.** Every recorder, the logger and the flush + swallow their own failures. +- **No unbounded labels.** Route patterns, never concrete paths; statement kind, + never SQL; no ids, no email addresses. +- **Flush before the freeze.** `dispatch()` flushes in a `finally`, capped at 2s + so a slow Grafana cannot become a 30s lambda timeout. +- **Delta temporality.** Cumulative counters from short-lived Lambda containers + read as a reset storm to Mimir. +- **Off is a valid state.** No endpoint means the OTel SDK is never `require`d, + so local dev and tests neither export nor pay the load cost. +- **Quiet in tests.** `NODE_ENV=test` defaults `LOG_LEVEL` to `error`, so the + access log does not drown jest output. + +## Cost + +The OTel SDK adds 245 KB minified to each bundle (528 KB total, 136 KB zipped). +Per invocation: buffered records plus one flush, bounded at 2s. + +## Not in scope + +- Traces / Tempo. +- Terraform changes. Preview stacks inherit the OTLP vars because + `preview-env.yml` copies the live lambda environment, so their telemetry + arrives tagged `pr-` — the same treatment their Sentry errors get. +- Dashboards and alert rules as code. The metric names above are the contract a + dashboard would build on. +- The frontend. Grafana Faro would be a separate piece of work. diff --git a/infrastructure/AGENTS.md b/infrastructure/AGENTS.md index fbf194e3..2c638684 100644 --- a/infrastructure/AGENTS.md +++ b/infrastructure/AGENTS.md @@ -11,7 +11,7 @@ Common to all modules: Terraform **1.13.0** (`.terraform-version`, tfenv), state ### `aws/` (state key `aws/terraform.tfstate`) Application infra. Providers: AWS 6.14.1, Infisical. - `main.tf` — RDS PostgreSQL 17.9 (db.t3.micro), `branch_rds` db; creds from Infisical `/aws/rds`. -- `lambda.tf` — 6 Lambda functions (auth/donors/expenditures/projects/reports/users, Node 20.x, 256MB, 30s), IAM role (CloudWatch Logs + pool-scoped `cognito-idp:AdminDeleteUser`/`AdminGetUser` for the registration-rollback and `DELETE /users/{userId}` paths + `s3:PutObject`/`s3:GetObject` on the reports bucket), deployment S3 bucket. **`lifecycle` ignores `s3_key` only** — code is deployed by CI (`lambda-deploy`), not Terraform. Env: `NODE_ENV`, `DB_*`, `COGNITO_USER_POOL_ID`, `COGNITO_CLIENT_ID`, `REPORTS_BUCKET_NAME`, `NODE_OPTIONS`, `SENTRY_DSN`, `SENTRY_ENVIRONMENT`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_HEADERS`. Attaches the Sentry layer (`modules/sentry`); the SDK lives only in that layer, never in the bundles, and `NODE_OPTIONS` is what activates it — drop either and errors stop reporting with no other symptom. `SENTRY_DSN` comes from Infisical `/sentry` (`sentry-dsn`) and `OTEL_EXPORTER_OTLP_HEADERS` from `/grafana`, not the tree. **The `environment` block is authoritative:** any var set by hand in the console and not declared here is deleted on the next apply, which previously took out authentication across all six lambdas. `AWS_REGION` is Lambda-reserved and must stay absent. +- `lambda.tf` — 6 Lambda functions (auth/donors/expenditures/projects/reports/users, Node 20.x, 256MB, 30s), IAM role (CloudWatch Logs + pool-scoped `cognito-idp:AdminDeleteUser`/`AdminGetUser` for the registration-rollback and `DELETE /users/{userId}` paths + `s3:PutObject`/`s3:GetObject` on the reports bucket), deployment S3 bucket. **`lifecycle` ignores `s3_key` only** — code is deployed by CI (`lambda-deploy`), not Terraform. Env: `NODE_ENV`, `DB_*`, `COGNITO_USER_POOL_ID`, `COGNITO_CLIENT_ID`, `REPORTS_BUCKET_NAME`, `NODE_OPTIONS`, `SENTRY_DSN`, `SENTRY_ENVIRONMENT`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_HEADERS`. Attaches the Sentry layer (`modules/sentry`); the SDK lives only in that layer, never in the bundles, and `NODE_OPTIONS` is what activates it — drop either and errors stop reporting with no other symptom. `SENTRY_DSN` comes from Infisical `/sentry` (`sentry-dsn`) and `OTEL_EXPORTER_OTLP_HEADERS` from `/grafana`, not the tree. The OTLP pair is consumed by `@branch/lambda-telemetry` (bundled into each function, unlike Sentry's layer) — dropping either stops metrics and logs reaching Grafana, but the lambdas keep serving and keep logging to CloudWatch. Preview stacks inherit both, because `preview-env.yml` copies the live `branch-auth`/`branch-reports` environment wholesale and strips only `NODE_ENV`/`AWS_*`; their telemetry lands in the same Grafana account tagged `deployment.environment.name = pr-`, exactly as their Sentry errors are. Set `OTEL_SDK_DISABLED = "true"` in `preview/lambda.tf`'s merge block if that ever needs to stop. **The `environment` block is authoritative:** any var set by hand in the console and not declared here is deleted on the next apply, which previously took out authentication across all six lambdas. `AWS_REGION` is Lambda-reserved and must stay absent. - `cognito.tf` — user pool (email sign-in, auto-verify, 8-char password policy, `advanced_security_mode = AUDIT`, `mfa_configuration = OPTIONAL` with `software_token_mfa_configuration { enabled = true }`, deletion protection) + public client (1h access/ID tokens, 30d refresh, no secret). Outputs `cognito_user_pool_id` / `cognito_client_id`; the lambdas get these from `lambda.tf` directly, no manual step. Infisical `/aws/cognito/` is still the source for the `COGNITO_*` GitHub Actions secrets used by `lambda-tests.yml` — keep it in sync if the pool is ever recreated. Threat protection is AUDIT rather than ENFORCED because every sign-in is proxied through the auth lambda, so adaptive auth would risk-score one shared ENI address. TOTP MFA is opt-in per user (`POST /auth/mfa-setup`/`/mfa-verify`/`/mfa-disable` in the auth lambda) — never forced at login. Both email bodies live in `aws/templates/*.html.tftpl` and are pulled in with `templatefile()` — edit the HTML there, not in the `.tf`. `invite-email.html.tftpl` feeds `admin_create_user_config` (sent by `POST /users` → AdminCreateUser); `verification-email.html.tftpl` feeds `verification_message_template`, which Cognito uses for **both** the sign-up code and the forgot-password code — keep that copy neutral between them, and keep `default_email_option = "CONFIRM_WITH_CODE"` because the auth lambda confirms with a code, not a link. Each template takes one variable, `app_url`. Their links come from `local.app_url` in `dns.tf`, which is the custom domain once attached and the CloudFront domain before that. `{username}` survives only inside an HTML comment: Cognito rejects an invite template that omits it, but `username_attributes = ["email"]` makes it render the pool-generated UUID, so it must never be shown. Sending is `COGNITO_DEFAULT` until `var.enable_ses_email` flips `email_configuration` to SES — see `email.tf`. - `dns.tf` — Route 53 zone + ACM certificates for `var.app_domain`, and `local.app_url`. Three states: empty `app_domain` creates nothing; `app_domain` set creates the zone and requests certificates; `+ enable_custom_domain` completes validation and attaches the domain to CloudFront and API Gateway. The second step is separate because ACM validates over public DNS and would otherwise block the apply for 45 minutes before failing. **`var.use_existing_hosted_zone` picks where the zone comes from:** false creates `aws_route53_zone.app` (domain registered elsewhere, NS set by hand at the registrar), true adopts the zone via `data.aws_route53_zone.app`. Set it to true when the domain is bought through Route 53 Domains — registration creates a hosted zone automatically and points the domain at it, so creating a second zone here would leave two zones with different nameservers and resolution silently following whichever set the registrar holds. Everything downstream reads `local.zone_id`, never the resource directly. - `email.tf` — outbound mail identity for `var.app_domain`: SES domain identity, Easy DKIM, a `mail.` custom MAIL FROM, and the Route 53 records that make them resolve (`_amazonses` TXT, three `_domainkey` CNAMEs, MAIL FROM MX + SPF, apex SPF, `_dmarc` TXT at `p=none`). Gated on `var.app_domain` like `dns.tf`, so an empty domain creates nothing. `var.enable_ses_email` is the second gate: it swaps `cognito.tf`'s `email_configuration` from `COGNITO_DEFAULT` to `DEVELOPER` sending as `no-reply@`. **Do not flip it until DKIM reads Verified in SES and the account has SES production access** — in the SES sandbox `DEVELOPER` mode can only mail verified addresses, so every invitation to a new user fails outright. `COGNITO_DEFAULT` sends from AWS's shared `no-reply@verificationemail.com`, which no domain can authenticate and which is capped at 50 messages/day; that is why invitations landed in spam. diff --git a/shared/lambda-http/package-lock.json b/shared/lambda-http/package-lock.json index 40401563..d056c08a 100644 --- a/shared/lambda-http/package-lock.json +++ b/shared/lambda-http/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@branch/lambda-auth": "file:../lambda-auth", + "@branch/lambda-telemetry": "file:../lambda-telemetry", "@branch/rbac": "file:../rbac" }, "devDependencies": { @@ -38,6 +39,27 @@ "typescript": "^5.4.5" } }, + "../lambda-telemetry": { + "name": "@branch/lambda-telemetry", + "version": "1.0.0", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/api-logs": "^0.222.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.222.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.222.0", + "@opentelemetry/resources": "^2.11.0", + "@opentelemetry/sdk-logs": "^0.222.0", + "@opentelemetry/sdk-metrics": "^2.11.0" + }, + "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, "../rbac": { "name": "@branch/rbac", "version": "1.0.0", @@ -550,6 +572,10 @@ "resolved": "../lambda-auth", "link": true }, + "node_modules/@branch/lambda-telemetry": { + "resolved": "../lambda-telemetry", + "link": true + }, "node_modules/@branch/rbac": { "resolved": "../rbac", "link": true diff --git a/shared/lambda-http/package.json b/shared/lambda-http/package.json index b25aa1aa..2b32a0f4 100644 --- a/shared/lambda-http/package.json +++ b/shared/lambda-http/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@branch/lambda-auth": "file:../lambda-auth", + "@branch/lambda-telemetry": "file:../lambda-telemetry", "@branch/rbac": "file:../rbac" }, "devDependencies": { diff --git a/shared/lambda-http/src/dispatch.ts b/shared/lambda-http/src/dispatch.ts index d54770db..81af8567 100644 --- a/shared/lambda-http/src/dispatch.ts +++ b/shared/lambda-http/src/dispatch.ts @@ -1,11 +1,32 @@ import type { APIGatewayProxyResult } from 'aws-lambda'; import { authorize } from '@branch/rbac'; +import { + enrichRequestContext, + flushTelemetry, + logger, + recordAuthFailure, + recordColdStart, + recordRequest, + recordUnhandledError, + runWithRequestContext, +} from '@branch/lambda-telemetry'; +import type { RequestContext } from '@branch/lambda-telemetry'; import { json } from './response'; import { matchPattern } from './match'; import { ANONYMOUS_AUTH } from './authz'; import { reportError } from './errors'; import type { DispatchOptions, RequestAuth, Route } from './types'; +/** Flipped by the first invocation this container serves. */ +let firstInvocation = true; + +/** + * The route label attached to metrics and logs. Always a *pattern*, never a + * concrete path — `/donors/42` as a label would give Prometheus one series per + * donor. + */ +const UNMATCHED_ROUTE = 'unmatched'; + /** * Route a Lambda event to the first matching route and return its response. * @@ -18,52 +39,124 @@ import type { DispatchOptions, RequestAuth, Route } from './types'; * Handles OPTIONS preflight, `//health`, authentication, the route's * declared permission, 404 and 500 centrally. A controller that runs has * already cleared its route's gate. + * + * Also the one place the backend is instrumented: every request produces a + * duration histogram sample, a counter increment and an access log, and the + * buffered telemetry is flushed before the handler returns. */ export async function dispatch( event: any, { prefix, routes, resolveAuth }: DispatchOptions, ): Promise { - // Hoisted out of the try so the catch can name the route that failed. - const rawPath: string = event?.rawPath || event?.path || '/'; - let path = rawPath.replace(/\/+$/, '') || '/'; - const method = ( - event?.requestContext?.http?.method || - event?.httpMethod || - 'GET' + const startedAt = Date.now(); + const coldStart = firstInvocation; + firstInvocation = false; + + const rawPath = String(event?.rawPath || event?.path || '/'); + const method = String( + event?.requestContext?.http?.method || event?.httpMethod || 'GET', ).toUpperCase(); - try { - // Canonicalize to `/...` when the prefix was stripped (dev-server). - const base = `/${prefix}`; - if (path !== base && !path.startsWith(`${base}/`)) { - path = path === '/' ? base : base + path; - } + // Canonicalize to `/...` when the prefix was stripped (dev-server). + const base = `/${prefix}`; + let path = rawPath.replace(/\/+$/, '') || '/'; + if (path !== base && !path.startsWith(`${base}/`)) { + path = path === '/' ? base : base + path; + } + + const context: RequestContext = { + requestId: event?.requestContext?.requestId, + service: prefix, + method, + path, + coldStart, + }; - if (method === 'OPTIONS') return json(200, {}); + return runWithRequestContext(context, async () => { + if (coldStart) recordColdStart(); - if (path === `${base}/health` && method === 'GET') { - return json(200, { ok: true, timestamp: new Date().toISOString() }); + let response: APIGatewayProxyResult; + try { + response = await route(event, { prefix, routes, resolveAuth }, { base, path, method }); + } catch (err) { + logger.error('Unhandled error', { error: err }); + recordUnhandledError(method, context.route ?? UNMATCHED_ROUTE); + // The layer's wrapHandler only records throws, and catching here is what + // gets API Gateway a JSON 500 instead of a 502 -- so hand it over by hand. + reportError(err, { method, path }); + response = json(500, { message: 'Internal Server Error' }); } - for (const route of routes) { - if (route.method.toUpperCase() !== method) continue; - const params = matchPattern(route.pattern, path); - if (!params) continue; + await complete(context, response, startedAt); + return response; + }); +} - const gate = await enforce(route, event, resolveAuth); - if ('response' in gate) return gate.response; +interface Target { + base: string; + path: string; + method: string; +} - return await route.handler({ event, params, method, path, auth: gate.auth }); - } +async function route( + event: any, + { routes, resolveAuth }: DispatchOptions, + { base, path, method }: Target, +): Promise { + if (method === 'OPTIONS') { + enrichRequestContext({ route: 'preflight' }); + return json(200, {}); + } - return json(404, { message: 'Not Found', path, method }); - } catch (err) { - console.error('Lambda error:', err); - // The layer's wrapHandler only records throws, and catching here is what - // gets API Gateway a JSON 500 instead of a 502 -- so hand it over by hand. - reportError(err, { method, path }); - return json(500, { message: 'Internal Server Error' }); + if (path === `${base}/health` && method === 'GET') { + enrichRequestContext({ route: `${base}/health` }); + return json(200, { ok: true, timestamp: new Date().toISOString() }); } + + for (const candidate of routes) { + if (candidate.method.toUpperCase() !== method) continue; + const params = matchPattern(candidate.pattern, path); + if (!params) continue; + + enrichRequestContext({ route: candidate.pattern }); + + const gate = await enforce(candidate, event, resolveAuth); + if ('response' in gate) return gate.response; + + enrichRequestContext({ userId: userIdOf(gate.auth) }); + return await candidate.handler({ event, params, method, path, auth: gate.auth }); + } + + enrichRequestContext({ route: UNMATCHED_ROUTE }); + return json(404, { message: 'Not Found', path, method }); +} + +/** + * Record the request and ship everything buffered. + * + * Lambda freezes the container as soon as the handler returns, so a flush that + * does not happen here never happens at all. + */ +async function complete( + context: RequestContext, + response: APIGatewayProxyResult, + startedAt: number, +): Promise { + const durationMs = Date.now() - startedAt; + const statusCode = response.statusCode; + const route = context.route ?? UNMATCHED_ROUTE; + + recordRequest({ method: context.method, route, statusCode, durationMs }); + + // Health checks are the majority of traffic on an idle stack; counting them is + // useful, narrating them is not. + const level = route.endsWith('/health') ? 'debug' : statusCode >= 500 ? 'error' : 'info'; + logger[level]('Request served', { + 'http.response.status_code': statusCode, + durationMs, + }); + + await flushTelemetry(); } type Gate = { auth: RequestAuth } | { response: APIGatewayProxyResult }; @@ -81,22 +174,31 @@ async function enforce( const err = new Error( `Route ${route.method} ${route.pattern} is guarded but the service passed no resolveAuth`, ); - console.error(err.message); + logger.error(err.message, { error: err }); reportError(err); return { response: json(500, { message: 'Internal Server Error' }) }; } const auth = await resolveAuth(event); if (!auth.context.isAuthenticated) { + recordAuthFailure(route.method.toUpperCase(), route.pattern, 'unauthenticated'); return { response: json(401, { message: 'Authentication required' }) }; } if (route.permission) { const decision = authorize(auth.subject, route.permission); if (!decision.allowed) { + recordAuthFailure(route.method.toUpperCase(), route.pattern, 'forbidden'); return { response: json(403, { message: decision.reason ?? 'Forbidden' }) }; } } return { auth }; } + +/** The caller's `branch.users` id, for the access log. Anonymous routes have none. */ +function userIdOf(auth: RequestAuth): string | undefined { + const user = auth.context.user; + const id = user?.dbUser?.userId ?? user?.userId; + return id === undefined ? undefined : String(id); +} diff --git a/shared/lambda-http/src/errors.ts b/shared/lambda-http/src/errors.ts index 693a1381..6c33eb87 100644 --- a/shared/lambda-http/src/errors.ts +++ b/shared/lambda-http/src/errors.ts @@ -1,4 +1,5 @@ import type { APIGatewayProxyResult } from 'aws-lambda'; +import { logger } from '@branch/lambda-telemetry'; import { json } from './response'; /** @@ -28,13 +29,16 @@ export function reportError(err: unknown, context?: Record): vo * catch — the status the caller sees is unchanged, but the error stops being * invisible. `body` merges extra fields into the response for the handful of * routes that already return more than a message. + * + * The log line goes to Loki as well as CloudWatch, carrying the request id and + * route from the enclosing `dispatch`. */ export function serverError( err: unknown, message: string, body?: Record, ): APIGatewayProxyResult { - console.error(`${message}:`, err); + logger.error(message, { error: err }); reportError(err); return json(500, { message, ...body }); } diff --git a/shared/lambda-http/test/telemetry.test.ts b/shared/lambda-http/test/telemetry.test.ts new file mode 100644 index 00000000..8e97d85a --- /dev/null +++ b/shared/lambda-http/test/telemetry.test.ts @@ -0,0 +1,236 @@ +import { ANONYMOUS, RbacSubject } from '@branch/rbac'; +import { logger } from '@branch/lambda-telemetry'; +import { dispatch } from '../src/dispatch'; +import { json } from '../src/response'; +import type { RequestAuth, Route } from '../src/types'; + +jest.mock('@sentry/aws-serverless', () => ({ captureException: jest.fn() }), { virtual: true }); + +jest.mock('@branch/lambda-telemetry', () => { + const actual = jest.requireActual('@branch/lambda-telemetry'); + return { + ...actual, + flushTelemetry: jest.fn(async () => undefined), + recordRequest: jest.fn(), + recordColdStart: jest.fn(), + recordAuthFailure: jest.fn(), + recordUnhandledError: jest.fn(), + }; +}); + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const telemetry = require('@branch/lambda-telemetry') as { + resetLoggerForTests?: () => void; + flushTelemetry: jest.Mock; + recordRequest: jest.Mock; + recordColdStart: jest.Mock; + recordAuthFailure: jest.Mock; + recordUnhandledError: jest.Mock; +}; + +// Literal segments before `:param` ones — first match wins. +const routes: Route[] = [ + { + method: 'GET', + pattern: '/projects/boom', + access: 'public', + handler: async () => { + throw new Error('kaboom'); + }, + }, + { + method: 'GET', + pattern: '/projects/:id', + access: 'authenticated', + handler: async () => json(200, { ok: true }), + }, + { + method: 'PUT', + pattern: '/projects/:id', + permission: 'project:update', + handler: async () => json(200, { ok: true }), + }, +]; + +const subject = (userId: number, isAdmin: boolean): RbacSubject => ({ + userId, + isAdmin, + memberProjectIds: [], + directorProjectIds: [], +}); + +const authed = + (userId: number, isAdmin = false) => + async (): Promise => ({ + context: { isAuthenticated: true, user: { cognitoSub: 'x', userId, isAdmin } }, + subject: subject(userId, isAdmin), + }); + +const anonymous = async (): Promise => ({ + context: { isAuthenticated: false }, + subject: ANONYMOUS, +}); + +const opts = (resolveAuth = authed(1, true)) => ({ prefix: 'projects', routes, resolveAuth }); + +const event = (method: string, rawPath: string) => ({ + rawPath, + requestContext: { http: { method }, requestId: 'req-abc' }, +}); + +let logged: Record[]; + +beforeEach(() => { + jest.clearAllMocks(); + // Jest defaults the level to `error`; the access log is info. + process.env.LOG_LEVEL = 'info'; + telemetry.resetLoggerForTests?.(); + logged = []; + const capture = (line: unknown) => { + try { + logged.push(JSON.parse(String(line))); + } catch { + logged.push({ raw: String(line) }); + } + }; + jest.spyOn(console, 'log').mockImplementation(capture); + jest.spyOn(console, 'warn').mockImplementation(capture); + jest.spyOn(console, 'error').mockImplementation(capture); +}); + +afterEach(() => jest.restoreAllMocks()); + +describe('request metrics', () => { + it('records the route pattern, not the concrete path', async () => { + await dispatch(event('GET', '/projects/42'), opts()); + + expect(telemetry.recordRequest).toHaveBeenCalledWith( + expect.objectContaining({ method: 'GET', route: '/projects/:id', statusCode: 200 }), + ); + const [{ route }] = telemetry.recordRequest.mock.calls[0]; + expect(route).not.toContain('42'); + }); + + it('labels an unmatched path as "unmatched" rather than echoing it', async () => { + await dispatch(event('GET', '/projects/7/nope'), opts()); + + expect(telemetry.recordRequest).toHaveBeenCalledWith( + expect.objectContaining({ route: 'unmatched', statusCode: 404 }), + ); + }); + + it('reports a duration', async () => { + await dispatch(event('GET', '/projects/42'), opts()); + + const [{ durationMs }] = telemetry.recordRequest.mock.calls[0]; + expect(typeof durationMs).toBe('number'); + expect(durationMs).toBeGreaterThanOrEqual(0); + }); + + it('counts the first invocation as a cold start and later ones not', async () => { + await dispatch(event('GET', '/projects/42'), opts()); + const afterFirst = telemetry.recordColdStart.mock.calls.length; + + await dispatch(event('GET', '/projects/42'), opts()); + expect(telemetry.recordColdStart.mock.calls.length).toBe(afterFirst); + }); +}); + +describe('auth failure metrics', () => { + it('counts a 401 as unauthenticated', async () => { + const res = await dispatch(event('GET', '/projects/42'), opts(anonymous)); + + expect(res.statusCode).toBe(401); + expect(telemetry.recordAuthFailure).toHaveBeenCalledWith( + 'GET', + '/projects/:id', + 'unauthenticated', + ); + }); + + it('counts a 403 as forbidden', async () => { + const res = await dispatch(event('PUT', '/projects/42'), opts(authed(9, false))); + + expect(res.statusCode).toBe(403); + expect(telemetry.recordAuthFailure).toHaveBeenCalledWith('PUT', '/projects/:id', 'forbidden'); + }); +}); + +describe('unhandled errors', () => { + it('counts the throw and still answers 500', async () => { + const res = await dispatch(event('GET', '/projects/boom'), opts()); + + expect(res.statusCode).toBe(500); + expect(telemetry.recordUnhandledError).toHaveBeenCalledWith('GET', '/projects/boom'); + }); +}); + +describe('access log', () => { + it('emits one line carrying the request id, route, status and caller', async () => { + await dispatch(event('GET', '/projects/42'), opts(authed(7, true))); + + const line = logged.find((entry) => entry.message === 'Request served'); + expect(line).toMatchObject({ + level: 'info', + service: 'projects', + 'http.request.method': 'GET', + 'http.route': '/projects/:id', + 'url.path': '/projects/42', + 'http.response.status_code': 200, + 'aws.request_id': 'req-abc', + 'enduser.id': '7', + }); + }); + + it('keeps health checks at debug so an idle stack stays quiet', async () => { + await dispatch(event('GET', '/projects/health'), opts()); + + expect(logged.find((entry) => entry.message === 'Request served')).toBeUndefined(); + expect(telemetry.recordRequest).toHaveBeenCalledWith( + expect.objectContaining({ route: '/projects/health', statusCode: 200 }), + ); + }); + + it('logs a 500 at error level', async () => { + await dispatch(event('GET', '/projects/boom'), opts()); + + const line = logged.find((entry) => entry.message === 'Request served'); + expect(line).toMatchObject({ level: 'error', 'http.response.status_code': 500 }); + }); + + it('carries the enclosing request into a controller log line', async () => { + const chatty: Route[] = [ + { + method: 'GET', + pattern: '/projects/chatty', + access: 'public', + handler: async () => { + logger.info('did a thing', { thing: 1 }); + return json(200, {}); + }, + }, + ]; + + await dispatch(event('GET', '/projects/chatty'), { prefix: 'projects', routes: chatty }); + + expect(logged.find((entry) => entry.message === 'did a thing')).toMatchObject({ + thing: 1, + 'http.route': '/projects/chatty', + 'aws.request_id': 'req-abc', + }); + }); +}); + +describe('flushing', () => { + it('flushes before returning, because Lambda freezes on return', async () => { + await dispatch(event('GET', '/projects/42'), opts()); + + expect(telemetry.flushTelemetry).toHaveBeenCalledTimes(1); + }); + + it('still flushes when the handler threw', async () => { + await dispatch(event('GET', '/projects/boom'), opts()); + + expect(telemetry.flushTelemetry).toHaveBeenCalledTimes(1); + }); +}); diff --git a/shared/lambda-telemetry/README.md b/shared/lambda-telemetry/README.md new file mode 100644 index 00000000..4fa1f504 --- /dev/null +++ b/shared/lambda-telemetry/README.md @@ -0,0 +1,94 @@ +# @branch/lambda-telemetry + +Metrics and structured logs from the lambdas to Grafana Cloud, over OTLP/HTTP. + +Terraform has set `OTEL_EXPORTER_OTLP_ENDPOINT` and `OTEL_EXPORTER_OTLP_HEADERS` +on all six functions since the Grafana account was created; until this package +existed nothing read them. This is the thing that reads them. + +## What you get for free + +`dispatch()` in `@branch/lambda-http` is instrumented, so **every route in every +service** already reports: + +| Signal | Name | Labels | +|---|---|---| +| Requests served | `http.server.requests` | method, route, status | +| Latency | `http.server.request.duration` (ms) | method, route, status | +| Cold starts | `faas.cold_starts` | — | +| Refused by the auth gate | `http.server.auth_failures` | method, route, `unauthenticated` \| `forbidden` | +| Errors reaching the dispatch catch | `http.server.unhandled_errors` | method, route | +| Query latency | `db.client.operation.duration` (ms) | statement kind, ok/error | + +plus one `Request served` log line per request, carrying the request id, route, +status, duration, cold-start flag and the caller's user id. + +Wiring a lambda needs nothing beyond what is already there, except the Kysely +hook in `db.ts`: + +```ts +import { kyselyTelemetryLog } from '@branch/lambda-telemetry'; + +const db = new Kysely({ log: kyselyTelemetryLog, dialect: ... }); +``` + +## Domain metrics + +Anything business-shaped is recorded explicitly from a controller, using a name +from the `METRICS` table — never a string literal, so a dashboard and the code +feeding it cannot drift apart: + +```ts +import { METRICS, recordEvent, recordValue } from '@branch/lambda-telemetry'; + +recordEvent(METRICS.LOGIN, { outcome: 'failure' }); +recordValue(METRICS.DONATION_AMOUNT, 250); +``` + +`recordEvent` counts; `recordValue` records a measurement into a histogram whose +unit is declared in `HISTOGRAM_SPECS`. + +## Logging + +```ts +import { logger } from '@branch/lambda-telemetry'; + +logger.info('Report generated', { reportId: 7 }); +logger.error('Upload failed', { error: err }); +``` + +Each call writes one JSON line to stdout (so CloudWatch is unchanged) **and** +one OTel log record to Loki. The enclosing request's fields are attached +automatically — controllers never pass a request id by hand. An `error` field +holding an `Error` is flattened into `error.type` / `error.message` / +`error.stack` rather than serialising to `{}`. + +`serverError()` from `@branch/lambda-http` already logs this way, so a catch +block that uses it needs nothing further. + +## Rules this package follows + +- **Never break a request.** Every recorder and the flush swallow their own + failures. A metrics outage must not become an outage. +- **No unbounded labels.** Route *patterns*, not paths; statement kinds, not + SQL; no ids, no email addresses. One bad label is one series per row forever. +- **Flush before returning.** Lambda freezes the container the moment the + handler returns, so `dispatch()` flushes in a `finally`, capped at 2s. +- **Off is a valid state.** With no `OTEL_EXPORTER_OTLP_ENDPOINT` the OTel SDK + is never even `require`d: local dev, tests and preview stacks log to stdout + and record nothing. `OTEL_SDK_DISABLED=true` forces the same. + +## Environment + +| Variable | Effect | +|---|---| +| `OTEL_EXPORTER_OTLP_ENDPOINT` | Gateway base URL. **Absent disables the SDK.** | +| `OTEL_EXPORTER_OTLP_HEADERS` | `key=value,...`, percent-decoded. Carries the Grafana instance id and token. | +| `OTEL_SDK_DISABLED` | `true` disables export, keeping stdout logs. | +| `LOG_LEVEL` | `debug` \| `info` \| `warn` \| `error`. Defaults to `info`, or `error` under `NODE_ENV=test`. | +| `OTEL_SERVICE_NAME` | Overrides `service.name`, which otherwise follows `AWS_LAMBDA_FUNCTION_NAME`. | +| `SENTRY_ENVIRONMENT` | Reused as `deployment.environment.name`. | + +Metrics are exported with **delta** temporality: Lambda containers are +short-lived, and cumulative counters from a churning fleet read as a reset storm +to Mimir. diff --git a/shared/lambda-telemetry/jest.config.js b/shared/lambda-telemetry/jest.config.js new file mode 100644 index 00000000..37b24d51 --- /dev/null +++ b/shared/lambda-telemetry/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/test/**/*.test.ts'], +}; diff --git a/shared/lambda-telemetry/package-lock.json b/shared/lambda-telemetry/package-lock.json new file mode 100644 index 00000000..5968280b --- /dev/null +++ b/shared/lambda-telemetry/package-lock.json @@ -0,0 +1,4919 @@ +{ + "name": "@branch/lambda-telemetry", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@branch/lambda-telemetry", + "version": "1.0.0", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/api-logs": "^0.222.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.222.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.222.0", + "@opentelemetry/resources": "^2.11.0", + "@opentelemetry/sdk-logs": "^0.222.0", + "@opentelemetry/sdk-metrics": "^2.11.0" + }, + "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.5.1.tgz", + "integrity": "sha512-u5Ncuc+gXVUwjNMFQOnphHo2Qx2DxyC8Dvpmf4HCx4y0kvSkRRNiQYRixrvOP4V7y52JcSuNxqochCt0PpdHVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.5.1", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.5.1", + "jest-util": "30.5.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/core": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.5.1.tgz", + "integrity": "sha512-BL9g6CJUUhIbdoAflz/Va658erSSXUIvU8XUYiWNTbZljJjZ5yaC9EJ9/dQ19rpbYV3ZOK4sBACqhPaTyhoUIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.5.1", + "@jest/pattern": "30.5.0", + "@jest/reporters": "30.5.1", + "@jest/test-result": "30.5.1", + "@jest/transform": "30.5.1", + "@jest/types": "30.5.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.5.1", + "jest-config": "30.5.1", + "jest-haste-map": "30.5.1", + "jest-message-util": "30.5.1", + "jest-regex-util": "30.5.0", + "jest-resolve": "30.5.1", + "jest-resolve-dependencies": "30.5.1", + "jest-runner": "30.5.1", + "jest-runtime": "30.5.1", + "jest-snapshot": "30.5.1", + "jest-util": "30.5.1", + "jest-validate": "30.5.1", + "jest-watcher": "30.5.1", + "pretty-format": "30.5.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.5.0.tgz", + "integrity": "sha512-OsqBjHXCn8cadasoAZBP6nWYvMsRhpMzGXTpxJ5aO04NlbdhIz+FVe3q49l0AwVhsz/cEmIpBes6gAFl1/dWQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.5.1.tgz", + "integrity": "sha512-eYJAkOsrpwDXPcoLNEG6lN9Zo3Cy35pxnVQD74vyIfsi/Q8wB/lZFsEjU4wrecOgT4ZviQDZaX/cFIJyMdMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.5.1", + "@jest/types": "30.5.1", + "@types/node": "*", + "jest-mock": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.5.1.tgz", + "integrity": "sha512-uOGd40P/COyUp9xHf5jeGiGJC2/ANg+2+Tk9/xN5/LxmlY/r/gxsPrx3DTEtaRZsLAX4wgNSLEDELZ5bmVs2bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "30.5.1", + "jest-snapshot": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.5.1.tgz", + "integrity": "sha512-WcRWhHQdTMRDpyWKZ/6MINBmovI7zeD+bL8wFjCncRV3NQOwKy1X45IfyblfHR4k/XciIlNEdFL9QjFO+HNKOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.5.1.tgz", + "integrity": "sha512-rEkV6YzpBXo/L9cnj2ibyuYZuyGiNWaPUsyCu3HYJbqCV4jnEiKmf7hId20z8eKjZ0JQ9jtIYKxRSmYB/OYSsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.5.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.5.1", + "jest-mock": "30.5.1", + "jest-util": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.5.0.tgz", + "integrity": "sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.5.1.tgz", + "integrity": "sha512-VhqvQ251XIC7pk46YymI3HCeBLOdvriDw9zibekodAHEwoVvbVVgpU5H13GvmyOAzxtZCfsm1uvzqkaqJf2I+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.5.1", + "@jest/expect": "30.5.1", + "@jest/types": "30.5.1", + "jest-mock": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.5.0.tgz", + "integrity": "sha512-HdNQYSdRTEBNrginaqzQtTjG0HRMfrra/z6Ok7uL3S87vSlarIVohEsJsSj5edu3MiHoHjAkvPROz5ZjoKai+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/react-is-19": { + "name": "react-is", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/reporters": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.5.1.tgz", + "integrity": "sha512-RbUXIfv85KxitJn4l3MpAoMilkvXe2QCO5lXxHWftywo/VdZ5vkEoHRDgphcxP6qmoIkUaN8/UZj6NhdkIFdJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.5.1", + "@jest/test-result": "30.5.1", + "@jest/transform": "30.5.1", + "@jest/types": "30.5.1", + "@jridgewell/trace-mapping": "^0.3.31", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^13.0.6", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.5.1", + "jest-util": "30.5.1", + "jest-worker": "30.5.1", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.5.1.tgz", + "integrity": "sha512-V3wnxNtiVmw5PPVg433Cn3VdXnsOeu/ofLw3KC04Bn2y1wIlU5kozXQn48rhrgj/02LrCVtntTEF1yBha0XSUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.5.1", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.5.0.tgz", + "integrity": "sha512-xWpTJP9D0bDFGbPGT8XuWSwwha/iHADyyKzUnMx4UbdgnHugxrDaQFO4RZ8x4ZsFzRP6pNii8uvlgKCDxCIuDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "callsites": "^3.1.0", + "convert-source-map": "^2.0.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.5.1.tgz", + "integrity": "sha512-A/1S6ZBdpic50E0pxLgvaB9XNPL4k7AksmG69OO2oiotxciWZwHkbOec+qbIi+uUtIIByOVlXJUl97oZUsz+Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.5.1", + "@jest/types": "30.5.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.5.1.tgz", + "integrity": "sha512-SHcPnrjdVRYJv6y6l4JUTy4Jccu7zmO6BmiOfOA34UiVBto22s19WiDC0A/2qfM7MWB/qdcoqfSIRMitpjTT4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.5.1", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.5.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.5.1.tgz", + "integrity": "sha512-EDnDhn0jleU9ZhpVoA4gvqw+Ev0iw/r5upNT2b79RiwiaTiYAMMhvNJP3lmocjIe5J2ZkoNr0B3K/J6O5GIm4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.5.1", + "@jridgewell/trace-mapping": "^0.3.31", + "babel-plugin-istanbul": "^8.0.0", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.5.1", + "jest-regex-util": "30.5.0", + "jest-util": "30.5.1", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.5.1.tgz", + "integrity": "sha512-LvVYn83nnXPl+Rg98nvcFgjx6nRMTArhSn6RAX/w3ELn54S8A42TYZvsCMdGUqTM8S0wyXbtlQdU6Hi6dykj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.5.0", + "@jest/schemas": "30.5.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", + "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.222.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.222.0.tgz", + "integrity": "sha512-9mb1If+IF6u0ZVXkHQ6ogEae5HwA6ajIVUgpSDQyRASxft6BSXHvBvPooRle3yFN/fKnCdSOnuu0OC3PLcF6+g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.11.0.tgz", + "integrity": "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http": { + "version": "0.222.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.222.0.tgz", + "integrity": "sha512-GqwLZohJTf/sttYlOpT1oFTJ9ScYaKnk0sOx4Qe/+KmXYVOa+v94cfnkUkFKDuvTODWhCL50Hz3gvsZD6ngQpg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/otlp-exporter-base": "0.222.0", + "@opentelemetry/otlp-transformer": "0.222.0", + "@opentelemetry/sdk-logs": "0.222.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http": { + "version": "0.222.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.222.0.tgz", + "integrity": "sha512-6Ko+0bgNP6V4G/RsmNBSk5lO1B/lo9z6tvLpFY3ykPPevXNzFmOW3C9hEmTt0zKCvHovyrol3TYlg4HVb5gwjg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/otlp-exporter-base": "0.222.0", + "@opentelemetry/otlp-transformer": "0.222.0", + "@opentelemetry/resources": "2.11.0", + "@opentelemetry/sdk-metrics": "2.11.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.222.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.222.0.tgz", + "integrity": "sha512-YbywG3veEm2Fb6TbdxRkuquWob6eVWXuA8/Ba1tXz9jHfUqpdE3keilOHEtPboC4CvS1bjeeVfNkWGOOrLj+lw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/otlp-transformer": "0.222.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.222.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.222.0.tgz", + "integrity": "sha512-/F3BZ89+CJQnZkMh2tCrtcdB+XT2Dxhj4FFE+WPQ//413hmFL0/RfEX6vgOIWGhiSzrkHWTK3+6SiT7K5/g/jQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.222.0", + "@opentelemetry/core": "2.11.0", + "@opentelemetry/resources": "2.11.0", + "@opentelemetry/sdk-logs": "0.222.0", + "@opentelemetry/sdk-metrics": "2.11.0", + "@opentelemetry/sdk-trace": "2.11.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.11.0.tgz", + "integrity": "sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.222.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.222.0.tgz", + "integrity": "sha512-+19YHODIjaUCArxleaJtuufFZVpz/xvvK+VllQqE+W8hHolxdoRwHfK/s667zezwh1hkx6FFF+oYzetYgqK+Bg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.222.0", + "@opentelemetry/core": "2.11.0", + "@opentelemetry/resources": "2.11.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.11.0.tgz", + "integrity": "sha512-7GXXcObyHyDUUSG+L+kJoquty01bzm7ivE7+SSgXXJcHuPzGviptxwARmI2c+bnnxjexGQbJnyNlN8HxBP/Y7A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/resources": "2.11.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.11.0.tgz", + "integrity": "sha512-fFnTqGm8/G73GQVnxYi7LXa1ZVYEUvgL6XI1LpvV0bPC7WQ/ZGgKxCSl8FnlZBKto9JHHEFTO6s6CUpvvtwFrA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/resources": "2.11.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/babel-jest": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.5.1.tgz", + "integrity": "sha512-ge1xUVZS91ml09YRMgRGgeKJ4YJpcOiuwteAxFBYLugQyp7cRw+hHej6Ho0vPjvLrjq60bb7JPHH9LAMA1/krA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.5.1", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^8.0.0", + "babel-preset-jest": "30.5.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-8.0.0.tgz", + "integrity": "sha512-18wCskrN3DgbuBmp1gr7LBGT8xdz5xhQQqFvFhVxbkl8VBCrMKQ2YtqBWtUal1Zrc1HTuX0011+Brjw78TCFkg==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^7.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.5.0.tgz", + "integrity": "sha512-gtGo1B+u14jrZQv6TdSWIWkTqclboo7Qn+dFAGUOIuXLFuJKAdg3U5MIWzZnW4vfUb4dX9skmAMiby86e/SF4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.5.0.tgz", + "integrity": "sha512-ZGPn5ClP4lBDpuOK8W1yQIOy359HmbnZv3suucAlIe+SEE9yDsxV4S2PjSbH2Vc97U+WmzYD7vw3kr8NsQ/i6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.5.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1 || ^8.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.1.tgz", + "integrity": "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.422", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", + "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.5.1.tgz", + "integrity": "sha512-m8YrYgvKe9+9gEnWEuKz+qCGfHqkrff7PPfyDnOFkjsfYRKqiYyOxDFMFieCGAuhtk/VNm63tvNgKZVzVy+Hvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.5.1", + "@jest/get-type": "30.5.0", + "jest-matcher-utils": "30.5.1", + "jest-message-util": "30.5.1", + "jest-mock": "30.5.1", + "jest-util": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.5.1.tgz", + "integrity": "sha512-3qrR8+ZXFnn7y0H2yjWQNkGGBLBY4zTRoTMAq9zJcgwLLtlyonfsCLviIXK9xuE2KgIyM+M36AFcoK2DgFR36w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.5.1", + "@jest/types": "30.5.1", + "import-local": "^3.2.0", + "jest-cli": "30.5.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.5.1.tgz", + "integrity": "sha512-0+bvMM/ENhDI29Z8q1r4HxiDIi4G5tnBmSw4esfPQoj9q8Nik7KIyuxHkTVnnniJQf05SxpGaKDQ+4h28ynGPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.5.1", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.5.1.tgz", + "integrity": "sha512-NgliezXQ6yznqR4W5Gqw++0cZSbBZlF0NknNIAE5VmSJKWOtmWJmWnBq3TSkUOVBTPrT7FGGhVdA8DLWnxo2Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.5.1", + "@jest/expect": "30.5.1", + "@jest/test-result": "30.5.1", + "@jest/types": "30.5.1", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.5.1", + "jest-matcher-utils": "30.5.1", + "jest-message-util": "30.5.1", + "jest-runtime": "30.5.1", + "jest-snapshot": "30.5.1", + "jest-util": "30.5.1", + "p-limit": "^3.1.0", + "pretty-format": "30.5.1", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.5.1.tgz", + "integrity": "sha512-uwNYepWgaNBCplm42fCIUZTf/tIEHIy5AYvx7eL1BrwIgyjPt+2poouR23UO2yopIP+kV8oZFHfv+l4pg/8prQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.5.1", + "@jest/test-result": "30.5.1", + "@jest/types": "30.5.1", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.5.1", + "jest-util": "30.5.1", + "jest-validate": "30.5.1", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.5.1.tgz", + "integrity": "sha512-L8PKM2X/ngG8PxfLMqglKGZjylPgw84bVPUlMY9W/o76TnLqPWrmQgsaT0HrheGdRtpGE5FbmREA0Kvb+Qx5gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.5.0", + "@jest/pattern": "30.5.0", + "@jest/test-sequencer": "30.5.1", + "@jest/types": "30.5.1", + "babel-jest": "30.5.1", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^13.0.6", + "graceful-fs": "^4.2.11", + "jest-circus": "30.5.1", + "jest-docblock": "30.5.0", + "jest-environment-node": "30.5.1", + "jest-regex-util": "30.5.0", + "jest-resolve": "30.5.1", + "jest-runner": "30.5.1", + "jest-util": "30.5.1", + "jest-validate": "30.5.1", + "parse-json": "^5.2.0", + "pretty-format": "30.5.1", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.5.1.tgz", + "integrity": "sha512-e3cNNMpv8Kh20MjjphTXs+3Vz7DQyLM1nft7KJhnh46atFhjVJRa+0Hq0beywuwsACtMQUBihQkFl8zxb7gt1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.5.0", + "@jest/get-type": "30.5.0", + "chalk": "^4.1.2", + "pretty-format": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.5.0.tgz", + "integrity": "sha512-NwDqcxtoZi33RhuW+zJS/RVA3rmheQ8BnwpYZuc/Eruaz6seQb7+aoCeDZu/3X7W2XmD8DbSo9Pn72DbKsBFYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.5.1.tgz", + "integrity": "sha512-S1af0TU4v1EZ/AUlkFs/sxf/5KGsbAT9kRgdyoMX/x72y7C8ZEgETE5o1TPnbKRnrbprc5FR/moYLfdRmqEjsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.5.0", + "@jest/types": "30.5.1", + "chalk": "^4.1.2", + "jest-util": "30.5.1", + "pretty-format": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.5.1.tgz", + "integrity": "sha512-LrPj3sPMjsQoOB3jrb8p/sa+XkSFNKo60TTsyc+EB2kxQJHgbwElpXnx1yX25fdFn5958FIObRtcLSHyV8VIAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.5.1", + "@jest/fake-timers": "30.5.1", + "@jest/types": "30.5.1", + "@types/node": "*", + "jest-mock": "30.5.1", + "jest-util": "30.5.1", + "jest-validate": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.5.1.tgz", + "integrity": "sha512-VIFgt67jW480YDxKfEv9IYQKrFpYt7bOCMn3VsnjK7AK9qo7p6acpnA1DHwsVOULE3dTaYew/6JaTD/d0VDHoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.5.1", + "@parcel/watcher": "^2.6.0", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "fdir": "^6.5.0", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.5.0", + "jest-util": "30.5.1", + "jest-worker": "30.5.1", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.5.1.tgz", + "integrity": "sha512-gt4GT2aWgEoCNTcBe4rqS74xTIUJxi+UD9SNSu9aOk5LmTEd6fS5KSTmAKAfitCCktQHNN+upUuL6EkBfFbMDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.5.0", + "pretty-format": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.5.1.tgz", + "integrity": "sha512-aroZVqwOz/wC2y6pC+obgFWKV9viaQWQTTSB6W5H55+egtUczIfXR/rxExTv92xD/ADYwpqaYfVWx1aqwKg7FA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.5.0", + "chalk": "^4.1.2", + "jest-diff": "30.5.1", + "pretty-format": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.1.tgz", + "integrity": "sha512-UdQlLdd9wL/Ys7xRErckqwD6wPlSZYueosSWuHc1r2ztGLwlgPvtSJq2+BPEgaEY13WLvfFbmhTj8pba0Sd1jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.5.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.5.1", + "picomatch": "^4.0.3", + "pretty-format": "30.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.5.1.tgz", + "integrity": "sha512-9fVjc3leUpGID2/by/LU4Dvdcp7PFh9LlxS3QRWK3ABm+KtvEVsG/AEGeLY3gKOZsjkBxyfwGltoAVlW7dygHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.5.1", + "@jest/types": "30.5.1", + "@types/node": "*", + "jest-util": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-regex-util": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.5.0.tgz", + "integrity": "sha512-Mg0WK7A6xRHLSA1udJ8y9f3lM0uUhFTBnLKzwPmqB9AylvpleJ6BLemR8K9dK27DY+cesDryoA7yLZCAHsPG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.5.1.tgz", + "integrity": "sha512-wprhLejRtwN6h8ZgaqC0eYjGJ1uMdGPT/+b3eFncbG4NWuuP9QL+vWwRinu9waw7hkOLcpMJGVJAMgBwsPssMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.5.1", + "jest-util": "30.5.1", + "jest-validate": "30.5.1", + "slash": "^3.0.0", + "unrs-resolver": "^1.12.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.5.1.tgz", + "integrity": "sha512-JKpXGONDcTaunrNVn8KCl6qAnwl06jIkvv70lhq0Ze47lsPx9sH5HoeEUiXX6iFGnliHN/qpIbQ0l38wrVmXaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "30.5.0", + "jest-snapshot": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.5.1.tgz", + "integrity": "sha512-FPlQE4+mwnFxXmpPrSi836KV2ZzvK1g6/nPCT8o5BcoDUUNJQeHo1/Qdkoe/QeoL4M7OeJpbnGUhYKhC1VMdaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.5.1", + "@jest/environment": "30.5.1", + "@jest/source-map": "30.5.0", + "@jest/test-result": "30.5.1", + "@jest/transform": "30.5.1", + "@jest/types": "30.5.1", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.5.0", + "jest-environment-node": "30.5.1", + "jest-haste-map": "30.5.1", + "jest-leak-detector": "30.5.1", + "jest-message-util": "30.5.1", + "jest-resolve": "30.5.1", + "jest-runtime": "30.5.1", + "jest-util": "30.5.1", + "jest-watcher": "30.5.1", + "jest-worker": "30.5.1", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.5.1.tgz", + "integrity": "sha512-UB88+NRkK2Tw/OqV7dofYcyiUGrVZtD41k/N0xQOs9fG//57XHK7JLWG52HD1UYpUAhjK4xm6DBvFX3yWudsMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.5.1", + "@jest/fake-timers": "30.5.1", + "@jest/globals": "30.5.1", + "@jest/source-map": "30.5.0", + "@jest/test-result": "30.5.1", + "@jest/transform": "30.5.1", + "@jest/types": "30.5.1", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.2.0", + "collect-v8-coverage": "^1.0.2", + "es-module-lexer": "^2.1.0", + "glob": "^13.0.6", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.5.1", + "jest-message-util": "30.5.1", + "jest-mock": "30.5.1", + "jest-regex-util": "30.5.0", + "jest-resolve": "30.5.1", + "jest-snapshot": "30.5.1", + "jest-util": "30.5.1", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.5.1.tgz", + "integrity": "sha512-cNWFdSb5xuDGl8hKkAZJ3YtI/PzHpAPFV+HUXWIOG8rMhpDTLVbvAc2d2wRicoYw2wGsJGLaurJT6BLC97bLXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.5.1", + "@jest/get-type": "30.5.0", + "@jest/snapshot-utils": "30.5.1", + "@jest/transform": "30.5.1", + "@jest/types": "30.5.1", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.5.1", + "graceful-fs": "^4.2.11", + "jest-diff": "30.5.1", + "jest-matcher-utils": "30.5.1", + "jest-message-util": "30.5.1", + "jest-util": "30.5.1", + "pretty-format": "30.5.1", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.1.tgz", + "integrity": "sha512-yKuxmNy2rSbTXw+3SIPanJo+nV4/BS1p26v44IYBFMsswSQySfMMcPHErnOncda7i9HEz0q605rIhSTBVgrZTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.5.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.5.1.tgz", + "integrity": "sha512-i/buJ56wTpxihE93hQYNMfdOThS87+HGvLZzZJmU4xggPcdTYQq051iwALLCHp3q+SkCGH+EFejQZz5EbBSkkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.5.0", + "@jest/types": "30.5.1", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.5.1.tgz", + "integrity": "sha512-+FHJ7C+S7b3ySfhA1aFmoa6TztsnwZVv84ycPRn0tVN93np2EU4b8C/KadqA3l6igdjgLBTnUotab9ER0HLG8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.5.1", + "@jest/types": "30.5.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.5.1", + "string-length": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.5.1.tgz", + "integrity": "sha512-Cbxh5v7AoLuFRmFJSM4/aHdQ68rjXvUWr716EE0Dh3I7T+T/3FgFKhOERGXHcU2Meftq9+9zxPM3TSNyI9D+HA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.5.1", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz", + "integrity": "sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/react-is-18": "npm:react-is@^18.3.1", + "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/schemas": "30.5.0", + "ansi-styles": "^5.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/synckit": { + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.3.6" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/test-exclude/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ts-jest": { + "version": "29.4.12", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.12.tgz", + "integrity": "sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.9", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.8.5", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/write-file-atomic/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/shared/lambda-telemetry/package.json b/shared/lambda-telemetry/package.json new file mode 100644 index 00000000..0a3e3bc5 --- /dev/null +++ b/shared/lambda-telemetry/package.json @@ -0,0 +1,33 @@ +{ + "name": "@branch/lambda-telemetry", + "version": "1.0.0", + "private": true, + "description": "OTLP metrics and structured logs for the lambdas. No-ops when Grafana Cloud is not configured.", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "prepare": "tsc", + "test": "jest" + }, + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/api-logs": "^0.222.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.222.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.222.0", + "@opentelemetry/resources": "^2.11.0", + "@opentelemetry/sdk-logs": "^0.222.0", + "@opentelemetry/sdk-metrics": "^2.11.0" + }, + "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/jest": "^30.0.0", + "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "typescript": "^5.4.5" + } +} diff --git a/shared/lambda-telemetry/src/config.ts b/shared/lambda-telemetry/src/config.ts new file mode 100644 index 00000000..0ba33f75 --- /dev/null +++ b/shared/lambda-telemetry/src/config.ts @@ -0,0 +1,81 @@ +export interface TelemetryConfig { + metricsUrl: string; + logsUrl: string; + headers: Record; + serviceName: string; + serviceVersion: string; + environment: string; + region: string; + logLevel: LogLevel; +} + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +const LEVELS: LogLevel[] = ['debug', 'info', 'warn', 'error']; + +/** + * Parse the OTLP env vars Terraform sets on every lambda. + * + * Returns `null` when there is nowhere to export to — local dev, tests, and any + * preview stack that never got the vars. Callers treat that as "no SDK", not as + * an error, so the OTel packages are never even required. + */ +export function readConfig(env: NodeJS.ProcessEnv = process.env): TelemetryConfig | null { + const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim(); + if (!endpoint || env.OTEL_SDK_DISABLED === 'true') return null; + + const base = endpoint.replace(/\/+$/, ''); + return { + metricsUrl: `${base}/v1/metrics`, + logsUrl: `${base}/v1/logs`, + headers: parseHeaders(env.OTEL_EXPORTER_OTLP_HEADERS), + ...identity(env), + }; +} + +/** Resource identity, which is also useful when the SDK is off (stdout logs carry it). */ +export function identity(env: NodeJS.ProcessEnv = process.env) { + return { + serviceName: env.OTEL_SERVICE_NAME || env.AWS_LAMBDA_FUNCTION_NAME || 'branch-local', + serviceVersion: env.AWS_LAMBDA_FUNCTION_VERSION || 'dev', + environment: env.SENTRY_ENVIRONMENT || env.NODE_ENV || 'development', + region: env.AWS_REGION || 'us-east-2', + logLevel: parseLevel(env.LOG_LEVEL, env.NODE_ENV), + }; +} + +/** `key=value,key2=value2`, values percent-encoded, per the OTLP spec. */ +export function parseHeaders(raw: string | undefined): Record { + const headers: Record = {}; + if (!raw) return headers; + + for (const pair of raw.split(',')) { + const eq = pair.indexOf('='); + if (eq <= 0) continue; + const key = pair.slice(0, eq).trim(); + if (!key) continue; + headers[key] = safeDecode(pair.slice(eq + 1).trim()); + } + return headers; +} + +function safeDecode(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function parseLevel(raw: string | undefined, nodeEnv?: string): LogLevel { + const level = raw?.trim().toLowerCase() as LogLevel | undefined; + if (level && LEVELS.includes(level)) return level; + // Jest sets NODE_ENV=test. An access log per request is the point in prod and + // pure noise in a test run, so default it down rather than make every lambda's + // jest config say so. + return nodeEnv === 'test' ? 'error' : 'info'; +} + +export function levelRank(level: LogLevel): number { + return LEVELS.indexOf(level); +} diff --git a/shared/lambda-telemetry/src/context.ts b/shared/lambda-telemetry/src/context.ts new file mode 100644 index 00000000..3928f6be --- /dev/null +++ b/shared/lambda-telemetry/src/context.ts @@ -0,0 +1,29 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +/** Fields every log line in a request carries, so a Loki query can follow one request. */ +export interface RequestContext { + requestId?: string; + service: string; + method: string; + path: string; + /** The matched route *pattern* (`/donors/:id`), never the concrete path — Prometheus label cardinality. */ + route?: string; + userId?: string; + coldStart: boolean; +} + +const storage = new AsyncLocalStorage(); + +export function runWithRequestContext(context: RequestContext, fn: () => Promise): Promise { + return storage.run(context, fn); +} + +export function currentRequestContext(): RequestContext | undefined { + return storage.getStore(); +} + +/** Fill in what is only known part-way through a request (the route, the caller). */ +export function enrichRequestContext(fields: Partial): void { + const context = storage.getStore(); + if (context) Object.assign(context, fields); +} diff --git a/shared/lambda-telemetry/src/db.ts b/shared/lambda-telemetry/src/db.ts new file mode 100644 index 00000000..5aa8959f --- /dev/null +++ b/shared/lambda-telemetry/src/db.ts @@ -0,0 +1,43 @@ +import { logger } from './logger'; +import { recordDbQuery } from './metrics'; + +/** The shape of a Kysely `LogEvent`, restated so this package needs no kysely dep. */ +interface QueryLogEvent { + level: 'query' | 'error'; + queryDurationMillis: number; + query: { sql: string }; + error?: unknown; +} + +/** Queries slower than this get their own log line. */ +const SLOW_QUERY_MS = 500; + +/** + * Kysely `log` hook: one duration histogram per statement kind, plus a line for + * anything slow or failed. + * + * Wire it in a lambda's `db.ts`: + * `new Kysely({ dialect, log: kyselyTelemetryLog })` + */ +export function kyselyTelemetryLog(event: QueryLogEvent): void { + const operation = statementKind(event.query?.sql); + const durationMs = Math.round(event.queryDurationMillis ?? 0); + const ok = event.level !== 'error'; + + recordDbQuery(operation, durationMs, ok); + + if (!ok) { + logger.error('Query failed', { 'db.operation.name': operation, durationMs, error: event.error }); + } else if (durationMs >= SLOW_QUERY_MS) { + logger.warn('Slow query', { 'db.operation.name': operation, durationMs }); + } +} + +/** + * The leading keyword, which is low-cardinality. The SQL itself is deliberately + * never a label: it embeds ids and would blow up the series count. + */ +function statementKind(sql: string | undefined): string { + const word = sql?.trimStart().split(/\s+/, 1)[0]?.toUpperCase(); + return word && /^[A-Z]+$/.test(word) ? word : 'UNKNOWN'; +} diff --git a/shared/lambda-telemetry/src/index.ts b/shared/lambda-telemetry/src/index.ts new file mode 100644 index 00000000..6d181095 --- /dev/null +++ b/shared/lambda-telemetry/src/index.ts @@ -0,0 +1,18 @@ +export type { LogLevel, TelemetryConfig } from './config'; +export type { RequestContext } from './context'; +export { currentRequestContext, enrichRequestContext, runWithRequestContext } from './context'; +export { flushTelemetry, telemetryConfig, resetTelemetryForTests } from './provider'; +export { log, logger, resetLoggerForTests, type LogFields } from './logger'; +export { kyselyTelemetryLog } from './db'; +export { + METRICS, + recordAuthFailure, + recordColdStart, + recordDbQuery, + recordEvent, + recordRequest, + recordUnhandledError, + recordValue, + type Attributes, + type RequestMetric, +} from './metrics'; diff --git a/shared/lambda-telemetry/src/logger.ts b/shared/lambda-telemetry/src/logger.ts new file mode 100644 index 00000000..999cfa17 --- /dev/null +++ b/shared/lambda-telemetry/src/logger.ts @@ -0,0 +1,115 @@ +import { identity, levelRank, type LogLevel } from './config'; +import { currentRequestContext } from './context'; +import { getOtelLogger } from './provider'; + +const SEVERITY: Record = { debug: 5, info: 9, warn: 13, error: 17 }; + +let threshold: number | undefined; + +function minLevel(): number { + if (threshold === undefined) threshold = levelRank(identity().logLevel); + return threshold; +} + +export interface LogFields { + /** An `Error` here is flattened into `error.type` / `error.message` / `error.stack`. */ + error?: unknown; + [field: string]: unknown; +} + +/** + * Write one structured line. + * + * Goes to stdout as JSON (so CloudWatch keeps working, and so a lambda with no + * OTLP endpoint still logs) *and* to Loki through the OTel logs exporter. The + * enclosing request's id, route and caller are attached automatically. + */ +export function log(level: LogLevel, message: string, fields: LogFields = {}): void { + if (levelRank(level) < minLevel()) return; + + const attributes = { ...requestFields(), ...flatten(fields) }; + + try { + const line = JSON.stringify({ level, message, ...attributes }); + if (level === 'error') console.error(line); + else if (level === 'warn') console.warn(line); + else console.log(line); + } catch { + // Something in `fields` is circular. The message alone still beats silence. + console.log(JSON.stringify({ level, message })); + } + + try { + getOtelLogger()?.emit({ + severityNumber: SEVERITY[level], + severityText: level.toUpperCase(), + body: message, + attributes, + }); + } catch (err) { + console.error('telemetry: log record dropped:', err); + } +} + +export const logger = { + debug: (message: string, fields?: LogFields) => log('debug', message, fields), + info: (message: string, fields?: LogFields) => log('info', message, fields), + warn: (message: string, fields?: LogFields) => log('warn', message, fields), + error: (message: string, fields?: LogFields) => log('error', message, fields), +}; + +function requestFields(): Record { + const context = currentRequestContext(); + if (!context) return {}; + + const fields: Record = { + service: context.service, + 'http.request.method': context.method, + 'url.path': context.path, + 'faas.coldstart': context.coldStart, + }; + if (context.requestId) fields['aws.request_id'] = context.requestId; + if (context.route) fields['http.route'] = context.route; + if (context.userId) fields['enduser.id'] = context.userId; + return fields; +} + +function flatten(fields: LogFields): Record { + const out: Record = {}; + + for (const [key, value] of Object.entries(fields)) { + if (key === 'error' || value === undefined || value === null) continue; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + out[key] = value; + } else { + out[key] = safeStringify(value); + } + } + + if ('error' in fields) Object.assign(out, describeError(fields.error)); + return out; +} + +function safeStringify(value: unknown): string { + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +} + +function describeError(err: unknown): Record { + if (err instanceof Error) { + return { + 'error.type': err.name, + 'error.message': err.message, + ...(err.stack ? { 'error.stack': err.stack } : {}), + }; + } + return { 'error.type': typeof err, 'error.message': String(err) }; +} + +/** Tests only — re-reads `LOG_LEVEL`. */ +export function resetLoggerForTests(): void { + threshold = undefined; +} diff --git a/shared/lambda-telemetry/src/metrics.ts b/shared/lambda-telemetry/src/metrics.ts new file mode 100644 index 00000000..ad434294 --- /dev/null +++ b/shared/lambda-telemetry/src/metrics.ts @@ -0,0 +1,161 @@ +import type { Counter, Histogram, Meter } from '@opentelemetry/api'; +import { getMeter } from './provider'; + +export type Attributes = Record; + +/** + * Domain metric names. Kept in one table so a dashboard and the code that feeds + * it cannot drift, and so nobody invents a second spelling of the same series. + */ +export const METRICS = { + LOGIN: 'branch.auth.logins', + REGISTRATION: 'branch.auth.registrations', + USER_INVITED: 'branch.users.invited', + PROJECT_CHANGED: 'branch.projects.changed', + EXPENDITURE_CHANGED: 'branch.expenditures.changed', + EXPENDITURE_AMOUNT: 'branch.expenditures.amount', + DONATION_RECORDED: 'branch.donations.recorded', + DONATION_AMOUNT: 'branch.donations.amount', + REPORT_GENERATED: 'branch.reports.generated', + REPORT_DURATION: 'branch.reports.generation.duration', + REPORT_SIZE: 'branch.reports.size', +} as const; + +interface HistogramSpec { + description: string; + unit: string; +} + +const HISTOGRAM_SPECS: Record = { + 'http.server.request.duration': { description: 'Time to serve a request', unit: 'ms' }, + 'db.client.operation.duration': { description: 'Time to run a query', unit: 'ms' }, + [METRICS.EXPENDITURE_AMOUNT]: { description: 'Expenditure amounts', unit: 'USD' }, + [METRICS.DONATION_AMOUNT]: { description: 'Donation amounts', unit: 'USD' }, + [METRICS.REPORT_DURATION]: { description: 'Time to render a report', unit: 'ms' }, + [METRICS.REPORT_SIZE]: { description: 'Generated report size', unit: 'By' }, +}; + +const counters = new Map(); +const histograms = new Map(); +let boundMeter: Meter | null = null; + +function meter(): Meter | null { + const current = getMeter(); + // A reset (tests) or a late init hands back a different meter; the cached + // instruments belong to the old one. + if (current !== boundMeter) { + counters.clear(); + histograms.clear(); + boundMeter = current; + } + return current; +} + +function counter(name: string, description: string): Counter | null { + const m = meter(); + if (!m) return null; + + let instrument = counters.get(name); + if (!instrument) { + instrument = m.createCounter(name, { description }); + counters.set(name, instrument); + } + return instrument; +} + +function histogram(name: string): Histogram | null { + const m = meter(); + if (!m) return null; + + let instrument = histograms.get(name); + if (!instrument) { + const spec = HISTOGRAM_SPECS[name] ?? { description: name, unit: '1' }; + instrument = m.createHistogram(name, spec); + histograms.set(name, instrument); + } + return instrument; +} + +/** Drop `undefined` values — OTel rejects them and they are never a useful label. */ +function clean(attributes: Attributes = {}): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(attributes)) { + if (value !== undefined) out[key] = value; + } + return out; +} + +/** + * Every recorder below swallows its own failures. A broken metric must never be + * the reason a request fails. + */ +function guard(fn: () => void): void { + try { + fn(); + } catch (err) { + console.error('telemetry: metric dropped:', err); + } +} + +export interface RequestMetric { + method: string; + route: string; + statusCode: number; + durationMs: number; +} + +export function recordRequest({ method, route, statusCode, durationMs }: RequestMetric): void { + guard(() => { + const attributes = { + 'http.request.method': method, + 'http.route': route, + 'http.response.status_code': statusCode, + }; + counter('http.server.requests', 'Requests served')?.add(1, attributes); + histogram('http.server.request.duration')?.record(durationMs, attributes); + }); +} + +export function recordColdStart(): void { + guard(() => counter('faas.cold_starts', 'Lambda cold starts')?.add(1)); +} + +/** `reason` is `unauthenticated` or `forbidden` — the two gates in `dispatch`. */ +export function recordAuthFailure(method: string, route: string, reason: string): void { + guard(() => + counter('http.server.auth_failures', 'Requests refused by the auth gate')?.add(1, { + 'http.request.method': method, + 'http.route': route, + reason, + }), + ); +} + +export function recordUnhandledError(method: string, route: string): void { + guard(() => + counter('http.server.unhandled_errors', 'Errors that reached the dispatch catch')?.add(1, { + 'http.request.method': method, + 'http.route': route, + }), + ); +} + +export function recordDbQuery(operation: string, durationMs: number, ok: boolean): void { + guard(() => + histogram('db.client.operation.duration')?.record(durationMs, { + 'db.operation.name': operation, + 'db.outcome': ok ? 'ok' : 'error', + }), + ); +} + +/** Count a domain event. Use a {@link METRICS} constant, not a literal. */ +export function recordEvent(name: string, attributes?: Attributes): void { + guard(() => counter(name, name)?.add(1, clean(attributes))); +} + +/** Record a domain measurement (money, duration). Use a {@link METRICS} constant. */ +export function recordValue(name: string, value: number, attributes?: Attributes): void { + if (!Number.isFinite(value)) return; + guard(() => histogram(name)?.record(value, clean(attributes))); +} diff --git a/shared/lambda-telemetry/src/provider.ts b/shared/lambda-telemetry/src/provider.ts new file mode 100644 index 00000000..6e5400db --- /dev/null +++ b/shared/lambda-telemetry/src/provider.ts @@ -0,0 +1,150 @@ +import type { Meter } from '@opentelemetry/api'; +import type { Logger as OtelLogger } from '@opentelemetry/api-logs'; +import { readConfig, type TelemetryConfig } from './config'; + +interface Providers { + meter: Meter; + otelLogger: OtelLogger; + flush: () => Promise; +} + +// `undefined` = not tried yet, `null` = no OTLP endpoint or the SDK failed to load. +let providers: Providers | null | undefined; +let config: TelemetryConfig | null | undefined; + +const FLUSH_TIMEOUT_MS = 2_000; +const EXPORT_TIMEOUT_MS = 5_000; + +/** How long the exporters batch before shipping on their own. We force-flush per invocation. */ +const BATCH_INTERVAL_MS = 60_000; + +export function telemetryConfig(): TelemetryConfig | null { + if (config === undefined) config = readConfig(); + return config; +} + +/** + * Build the OTel providers on first use, or return `null` forever if there is no + * endpoint. The SDK is `require`d lazily so an unconfigured lambda never pays to + * load it, and an SDK that throws on init degrades to stdout-only logging rather + * than taking the request with it. + */ +function getProviders(): Providers | null { + if (providers !== undefined) return providers; + + const cfg = telemetryConfig(); + if (!cfg) { + providers = null; + return null; + } + + try { + providers = build(cfg); + } catch (err) { + console.error('telemetry: OTel init failed, continuing without it:', err); + providers = null; + } + return providers; +} + +function build(cfg: TelemetryConfig): Providers { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { resourceFromAttributes } = require('@opentelemetry/resources'); + const { MeterProvider, PeriodicExportingMetricReader } = require('@opentelemetry/sdk-metrics'); + const { LoggerProvider, BatchLogRecordProcessor } = require('@opentelemetry/sdk-logs'); + const { + OTLPMetricExporter, + AggregationTemporalityPreference, + } = require('@opentelemetry/exporter-metrics-otlp-http'); + const { OTLPLogExporter } = require('@opentelemetry/exporter-logs-otlp-http'); + /* eslint-enable @typescript-eslint/no-require-imports */ + + const resource = resourceFromAttributes({ + 'service.name': cfg.serviceName, + 'service.namespace': 'branch', + 'service.version': cfg.serviceVersion, + 'deployment.environment.name': cfg.environment, + 'cloud.provider': 'aws', + 'cloud.platform': 'aws_lambda', + 'cloud.region': cfg.region, + 'faas.name': cfg.serviceName, + }); + + const meterProvider = new MeterProvider({ + resource, + readers: [ + new PeriodicExportingMetricReader({ + // Lambda instances are short-lived, so cumulative counters would look + // like a reset storm to Mimir. Delta lets the gateway do the summing. + exporter: new OTLPMetricExporter({ + url: cfg.metricsUrl, + headers: cfg.headers, + timeoutMillis: EXPORT_TIMEOUT_MS, + temporalityPreference: AggregationTemporalityPreference.DELTA, + }), + exportIntervalMillis: BATCH_INTERVAL_MS, + exportTimeoutMillis: EXPORT_TIMEOUT_MS, + }), + ], + }); + + const loggerProvider = new LoggerProvider({ + resource, + processors: [ + new BatchLogRecordProcessor( + new OTLPLogExporter({ + url: cfg.logsUrl, + headers: cfg.headers, + timeoutMillis: EXPORT_TIMEOUT_MS, + }), + { scheduledDelayMillis: BATCH_INTERVAL_MS }, + ), + ], + }); + + return { + meter: meterProvider.getMeter('@branch/lambda-telemetry'), + otelLogger: loggerProvider.getLogger('@branch/lambda-telemetry'), + flush: () => Promise.all([meterProvider.forceFlush(), loggerProvider.forceFlush()]), + }; +} + +export function getMeter(): Meter | null { + return getProviders()?.meter ?? null; +} + +export function getOtelLogger(): OtelLogger | null { + return getProviders()?.otelLogger ?? null; +} + +/** + * Ship whatever is buffered. Lambda freezes the instance the moment the handler + * returns, so nothing that is not flushed here is ever seen. + * + * Never rejects and never blocks longer than {@link FLUSH_TIMEOUT_MS}: Grafana + * being slow must not turn into a 30s lambda timeout. + */ +export async function flushTelemetry(): Promise { + const active = providers; + if (!active) return; + + let timer: NodeJS.Timeout | undefined; + try { + await Promise.race([ + active.flush(), + new Promise((resolve) => { + timer = setTimeout(resolve, FLUSH_TIMEOUT_MS); + }), + ]); + } catch (err) { + console.error('telemetry: flush failed:', err); + } finally { + if (timer) clearTimeout(timer); + } +} + +/** Tests only — drops the memoised providers so a new env can be picked up. */ +export function resetTelemetryForTests(): void { + providers = undefined; + config = undefined; +} diff --git a/shared/lambda-telemetry/test/config.test.ts b/shared/lambda-telemetry/test/config.test.ts new file mode 100644 index 00000000..94074513 --- /dev/null +++ b/shared/lambda-telemetry/test/config.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from '@jest/globals'; +import { identity, parseHeaders, readConfig } from '../src/config'; + +describe('readConfig', () => { + it('is null without an endpoint, so an unconfigured lambda never loads the SDK', () => { + expect(readConfig({})).toBeNull(); + }); + + it('is null when the SDK is explicitly disabled', () => { + const env = { OTEL_EXPORTER_OTLP_ENDPOINT: 'https://otlp.example', OTEL_SDK_DISABLED: 'true' }; + expect(readConfig(env)).toBeNull(); + }); + + it('appends the signal paths to the gateway base url', () => { + const config = readConfig({ OTEL_EXPORTER_OTLP_ENDPOINT: 'https://otlp.example/otlp/' }); + expect(config?.metricsUrl).toBe('https://otlp.example/otlp/v1/metrics'); + expect(config?.logsUrl).toBe('https://otlp.example/otlp/v1/logs'); + }); + + it('names the service after the lambda function', () => { + const config = readConfig({ + OTEL_EXPORTER_OTLP_ENDPOINT: 'https://otlp.example', + AWS_LAMBDA_FUNCTION_NAME: 'branch-donors', + SENTRY_ENVIRONMENT: 'production', + }); + expect(config?.serviceName).toBe('branch-donors'); + expect(config?.environment).toBe('production'); + }); +}); + +describe('parseHeaders', () => { + it('splits the OTLP header list and percent-decodes values', () => { + expect(parseHeaders('Authorization=Basic%20abc,X-Scope-OrgID=42')).toEqual({ + Authorization: 'Basic abc', + 'X-Scope-OrgID': '42', + }); + }); + + it('keeps a value containing "=" intact', () => { + expect(parseHeaders('Authorization=Basic dGVzdA==')).toEqual({ + Authorization: 'Basic dGVzdA==', + }); + }); + + it('ignores blanks and malformed pairs rather than throwing', () => { + expect(parseHeaders(undefined)).toEqual({}); + expect(parseHeaders('novalue,=orphan,a=1')).toEqual({ a: '1' }); + }); +}); + +describe('identity', () => { + it('falls back to development defaults off Lambda', () => { + expect(identity({})).toEqual({ + serviceName: 'branch-local', + serviceVersion: 'dev', + environment: 'development', + region: 'us-east-2', + logLevel: 'info', + }); + }); + + it('rejects an unknown LOG_LEVEL instead of silencing everything', () => { + expect(identity({ LOG_LEVEL: 'chatty' }).logLevel).toBe('info'); + expect(identity({ LOG_LEVEL: 'DEBUG' }).logLevel).toBe('debug'); + }); + + it('quiets the access log under jest, but still obeys an explicit level', () => { + expect(identity({ NODE_ENV: 'test' }).logLevel).toBe('error'); + expect(identity({ NODE_ENV: 'test', LOG_LEVEL: 'info' }).logLevel).toBe('info'); + }); +}); diff --git a/shared/lambda-telemetry/test/db.test.ts b/shared/lambda-telemetry/test/db.test.ts new file mode 100644 index 00000000..4ef218b3 --- /dev/null +++ b/shared/lambda-telemetry/test/db.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { kyselyTelemetryLog } from '../src/db'; +import { resetLoggerForTests } from '../src/logger'; +import { resetTelemetryForTests } from '../src/provider'; + +let written: Record[]; + +function event(overrides: Record = {}) { + return { + level: 'query' as const, + queryDurationMillis: 10, + query: { sql: 'select * from branch.donors where donor_id = $1' }, + ...overrides, + }; +} + +beforeEach(() => { + written = []; + const record = (line: unknown) => { + written.push(JSON.parse(String(line))); + }; + jest.spyOn(console, 'log').mockImplementation(record); + jest.spyOn(console, 'warn').mockImplementation(record); + jest.spyOn(console, 'error').mockImplementation(record); + delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; + // Jest defaults the level to `error`; the slow-query line is a warning. + process.env.LOG_LEVEL = 'info'; + resetTelemetryForTests(); + resetLoggerForTests(); +}); + +afterEach(() => { + jest.restoreAllMocks(); + resetLoggerForTests(); +}); + +describe('kyselyTelemetryLog', () => { + it('stays quiet for a fast query', () => { + kyselyTelemetryLog(event()); + expect(written).toHaveLength(0); + }); + + it('warns once a query crosses the slow threshold', () => { + kyselyTelemetryLog(event({ queryDurationMillis: 812 })); + + expect(written).toEqual([ + { level: 'warn', message: 'Slow query', 'db.operation.name': 'SELECT', durationMs: 812 }, + ]); + }); + + it('logs a failed query with the error flattened', () => { + kyselyTelemetryLog(event({ level: 'error', error: new Error('deadlock detected') })); + + expect(written[0]).toMatchObject({ + level: 'error', + message: 'Query failed', + 'db.operation.name': 'SELECT', + 'error.message': 'deadlock detected', + }); + }); + + it('labels by statement kind only, never by the SQL itself', () => { + kyselyTelemetryLog( + event({ queryDurationMillis: 900, query: { sql: ' insert into branch.users values (1)' } }), + ); + + const [line] = written; + expect(line['db.operation.name']).toBe('INSERT'); + expect(JSON.stringify(line)).not.toContain('branch.users'); + }); + + it('does not throw on a missing or odd statement', () => { + expect(() => + kyselyTelemetryLog(event({ queryDurationMillis: 700, query: { sql: '' } })), + ).not.toThrow(); + expect(written[0]['db.operation.name']).toBe('UNKNOWN'); + }); +}); diff --git a/shared/lambda-telemetry/test/logger.test.ts b/shared/lambda-telemetry/test/logger.test.ts new file mode 100644 index 00000000..1af31116 --- /dev/null +++ b/shared/lambda-telemetry/test/logger.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { logger, resetLoggerForTests } from '../src/logger'; +import { runWithRequestContext } from '../src/context'; +import { resetTelemetryForTests } from '../src/provider'; + +const ORIGINAL_ENV = { ...process.env }; +let written: string[]; + +function captured(): Record[] { + return written.map((line) => JSON.parse(line)); +} + +beforeEach(() => { + written = []; + const record = (line: unknown) => { + written.push(String(line)); + }; + jest.spyOn(console, 'log').mockImplementation(record); + jest.spyOn(console, 'warn').mockImplementation(record); + jest.spyOn(console, 'error').mockImplementation(record); + delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; + // Jest defaults the level to `error`; these tests are about the levels below it. + process.env.LOG_LEVEL = 'info'; + resetTelemetryForTests(); + resetLoggerForTests(); +}); + +afterEach(() => { + jest.restoreAllMocks(); + process.env = { ...ORIGINAL_ENV }; + resetLoggerForTests(); +}); + +describe('logger', () => { + it('writes one JSON line carrying the level and message', () => { + logger.info('Report generated', { reportId: 7 }); + + expect(captured()).toEqual([{ level: 'info', message: 'Report generated', reportId: 7 }]); + }); + + it('drops debug below the default threshold but keeps it when asked', () => { + logger.debug('noisy'); + expect(written).toHaveLength(0); + + process.env.LOG_LEVEL = 'debug'; + resetLoggerForTests(); + logger.debug('noisy'); + expect(written).toHaveLength(1); + }); + + it('flattens an Error rather than serialising it to {}', () => { + logger.error('Query failed', { error: new TypeError('bad input') }); + + const [line] = captured(); + expect(line['error.type']).toBe('TypeError'); + expect(line['error.message']).toBe('bad input'); + expect(line['error.stack']).toEqual(expect.stringContaining('TypeError')); + }); + + it('describes a non-Error throw too', () => { + logger.error('odd', { error: 'a string' }); + + const [line] = captured(); + expect(line['error.type']).toBe('string'); + expect(line['error.message']).toBe('a string'); + }); + + it('attaches the enclosing request so a Loki query can follow one call', async () => { + await runWithRequestContext( + { + requestId: 'req-1', + service: 'branch-donors', + method: 'GET', + path: '/donors/9', + route: '/donors/:id', + userId: '42', + coldStart: true, + }, + async () => logger.warn('Slow query', { durationMs: 900 }), + ); + + expect(captured()[0]).toEqual({ + level: 'warn', + message: 'Slow query', + service: 'branch-donors', + 'http.request.method': 'GET', + 'url.path': '/donors/9', + 'faas.coldstart': true, + 'aws.request_id': 'req-1', + 'http.route': '/donors/:id', + 'enduser.id': '42', + durationMs: 900, + }); + }); + + it('survives a circular field instead of throwing inside the logger', () => { + const circular: Record = {}; + circular.self = circular; + + expect(() => logger.info('cycle', { circular })).not.toThrow(); + expect(written).toHaveLength(1); + }); +}); diff --git a/shared/lambda-telemetry/test/metrics.test.ts b/shared/lambda-telemetry/test/metrics.test.ts new file mode 100644 index 00000000..af087d6d --- /dev/null +++ b/shared/lambda-telemetry/test/metrics.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'; +import { + METRICS, + recordAuthFailure, + recordColdStart, + recordEvent, + recordRequest, + recordUnhandledError, + recordValue, +} from '../src/metrics'; +import { getMeter, resetTelemetryForTests } from '../src/provider'; + +const ORIGINAL_ENV = { ...process.env }; + +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + resetTelemetryForTests(); +}); + +describe('recorders with no exporter', () => { + beforeEach(() => { + delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; + resetTelemetryForTests(); + }); + + it('are no-ops rather than throwing, so local dev is unaffected', () => { + expect(() => { + recordColdStart(); + recordRequest({ method: 'GET', route: '/donors/:id', statusCode: 200, durationMs: 12 }); + recordAuthFailure('GET', '/donors/:id', 'forbidden'); + recordUnhandledError('GET', '/donors/:id'); + recordEvent(METRICS.LOGIN, { outcome: 'success' }); + recordValue(METRICS.DONATION_AMOUNT, 250); + }).not.toThrow(); + }); +}); + +describe('recorders with an exporter', () => { + beforeEach(() => { + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = 'http://127.0.0.1:4318'; + resetTelemetryForTests(); + }); + + it('records against the live meter without throwing', () => { + expect(getMeter()).not.toBeNull(); + expect(() => { + recordRequest({ method: 'POST', route: '/donors', statusCode: 201, durationMs: 40 }); + recordEvent(METRICS.REPORT_GENERATED, { report_type: 'technical', outcome: 'success' }); + recordValue(METRICS.DONATION_AMOUNT, 500, { currency: 'USD' }); + }).not.toThrow(); + }); + + it('drops a non-finite measurement instead of poisoning the histogram', () => { + expect(() => recordValue(METRICS.DONATION_AMOUNT, Number.NaN)).not.toThrow(); + expect(() => recordValue(METRICS.EXPENDITURE_AMOUNT, Number.POSITIVE_INFINITY)).not.toThrow(); + }); + + it('skips undefined attributes, which OTel would reject', () => { + expect(() => + recordEvent(METRICS.LOGIN, { outcome: 'success', challenge: undefined }), + ).not.toThrow(); + }); +}); diff --git a/shared/lambda-telemetry/test/provider.test.ts b/shared/lambda-telemetry/test/provider.test.ts new file mode 100644 index 00000000..af716d50 --- /dev/null +++ b/shared/lambda-telemetry/test/provider.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it } from '@jest/globals'; +import { flushTelemetry, resetTelemetryForTests, telemetryConfig } from '../src/provider'; +import { getMeter, getOtelLogger } from '../src/provider'; + +const ORIGINAL_ENV = { ...process.env }; + +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + resetTelemetryForTests(); +}); + +describe('providers, unconfigured', () => { + it('hands back nothing and flushing is a no-op', async () => { + delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; + resetTelemetryForTests(); + + expect(telemetryConfig()).toBeNull(); + expect(getMeter()).toBeNull(); + expect(getOtelLogger()).toBeNull(); + await expect(flushTelemetry()).resolves.toBeUndefined(); + }); +}); + +describe('providers, configured', () => { + // Exercises the real OTel constructors: an SDK API change shows up here as a + // null meter rather than as silently missing metrics in production. + it('builds a meter and a logger from the OTLP env vars', () => { + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = 'http://127.0.0.1:4318'; + process.env.OTEL_EXPORTER_OTLP_HEADERS = 'Authorization=Basic%20abc'; + process.env.AWS_LAMBDA_FUNCTION_NAME = 'branch-donors'; + resetTelemetryForTests(); + + expect(getMeter()).not.toBeNull(); + expect(getOtelLogger()).not.toBeNull(); + }); + + it('resolves even though the collector is unreachable', async () => { + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = 'http://127.0.0.1:4318'; + resetTelemetryForTests(); + + getMeter()?.createCounter('test.counter').add(1); + await expect(flushTelemetry()).resolves.toBeUndefined(); + }, 15_000); +}); diff --git a/shared/lambda-telemetry/tsconfig.json b/shared/lambda-telemetry/tsconfig.json new file mode 100644 index 00000000..e57dbd9f --- /dev/null +++ b/shared/lambda-telemetry/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "esModuleInterop": true, + "moduleResolution": "node", + "strict": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From 5be37627e1ccb0468dad2245af64a6916ad8f45f Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Sun, 6 Sep 2026 23:45:51 -0400 Subject: [PATCH 2/4] chore: trim comments Collapse the explanatory blocks added by the telemetry work to one terse line each, and drop the ones that only restate the code. Co-Authored-By: Claude Opus 5 (1M context) --- apps/backend/lambdas/auth/controllers/auth.ts | 5 ++-- .../lambdas/donors/controllers/donations.ts | 3 +-- .../expenditures/controllers/expenditures.ts | 4 +--- .../lambdas/projects/controllers/projects.ts | 2 -- .../lambdas/reports/controllers/reports.ts | 4 +--- shared/lambda-http/src/dispatch.ts | 23 ++++--------------- shared/lambda-http/src/errors.ts | 3 --- shared/lambda-telemetry/src/config.ts | 13 ++--------- shared/lambda-telemetry/src/context.ts | 5 ++-- shared/lambda-telemetry/src/db.ts | 14 ++--------- shared/lambda-telemetry/src/logger.ts | 7 ++---- shared/lambda-telemetry/src/metrics.ts | 14 +++-------- shared/lambda-telemetry/src/provider.ts | 21 +++++------------ shared/lambda-telemetry/test/provider.test.ts | 3 +-- 14 files changed, 28 insertions(+), 93 deletions(-) diff --git a/apps/backend/lambdas/auth/controllers/auth.ts b/apps/backend/lambdas/auth/controllers/auth.ts index b937bf9d..a930ec4c 100644 --- a/apps/backend/lambdas/auth/controllers/auth.ts +++ b/apps/backend/lambdas/auth/controllers/auth.ts @@ -64,7 +64,7 @@ export async function handleLogin(event: any): Promise { } if (response.ChallengeName) { - // The challenge name is a small fixed set, so it is safe as a label. + // A small fixed set, so safe as a label. recordEvent(METRICS.LOGIN, { outcome: 'challenge', challenge: response.ChallengeName }); // MFA_SETUP cannot be answered by RespondToAuthChallenge alone -- it needs @@ -86,8 +86,7 @@ export async function handleLogin(event: any): Promise { 'Unexpected response from authentication service', ); } catch (error: any) { - // A spike here is the credential-stuffing signal. The address is - // deliberately not a label: it is PII and unbounded cardinality. + // The address is not a label: PII, and unbounded cardinality. recordEvent(METRICS.LOGIN, { outcome: 'failure', 'error.type': error?.name ?? 'Unknown' }); return mapCognitoAuthError(error, 'login'); } diff --git a/apps/backend/lambdas/donors/controllers/donations.ts b/apps/backend/lambdas/donors/controllers/donations.ts index 3b583045..280ce1ca 100644 --- a/apps/backend/lambdas/donors/controllers/donations.ts +++ b/apps/backend/lambdas/donors/controllers/donations.ts @@ -144,8 +144,7 @@ export async function createDonation({ event }: RouteCtx) { .returningAll() .executeTakeFirstOrThrow(); - // Donation rate and size. Ids stay out of the labels — one series per donor - // would be unbounded, and the rollups already answer per-project questions. + // Ids stay out of the labels; the rollups answer per-project questions. recordEvent(METRICS.DONATION_RECORDED, { backdated: donatedAt !== undefined }); recordValue(METRICS.DONATION_AMOUNT, donationAmount); diff --git a/apps/backend/lambdas/expenditures/controllers/expenditures.ts b/apps/backend/lambdas/expenditures/controllers/expenditures.ts index 5f6e5bdf..289f1525 100644 --- a/apps/backend/lambdas/expenditures/controllers/expenditures.ts +++ b/apps/backend/lambdas/expenditures/controllers/expenditures.ts @@ -130,8 +130,7 @@ export const createExpenditure: RouteHandler = async ({ event, auth }) => { return serverError(err, 'Failed to create expenditure'); } - // `category` is a small controlled vocabulary; `projectID` deliberately is not - // a label, since it grows without bound. + // `projectID` is not a label; it grows without bound. recordEvent(METRICS.EXPENDITURE_CHANGED, { action: 'created', status: effectiveStatus, @@ -367,7 +366,6 @@ export const patchExpenditureStatus: RouteHandler = async ({ event, params }) => return json(404, { message: 'Expenditure not found' }); } - // The approval funnel: rate of approved vs denied vs needs_more_info. recordEvent(METRICS.EXPENDITURE_CHANGED, { action: 'reviewed', status: updated.status }); // Email on approve/denial — best-effort, never blocks the response. diff --git a/apps/backend/lambdas/projects/controllers/projects.ts b/apps/backend/lambdas/projects/controllers/projects.ts index e03c033a..e9fc3371 100644 --- a/apps/backend/lambdas/projects/controllers/projects.ts +++ b/apps/backend/lambdas/projects/controllers/projects.ts @@ -178,8 +178,6 @@ export const deleteProject: RouteHandler = async ({ params }) => { // which is the single largest source of unreferenced objects. const filesDeleted = await deleteProjectObjects(Number(id)); - // A cascade delete takes expenditures, reports and their S3 objects with it, - // so this is worth being able to correlate against on a dashboard. recordEvent(METRICS.PROJECT_CHANGED, { action: 'deleted' }); return json(200, { ok: true, route: 'DELETE /projects/{projectId}', pathParams: { id }, filesDeleted }); diff --git a/apps/backend/lambdas/reports/controllers/reports.ts b/apps/backend/lambdas/reports/controllers/reports.ts index 4264be46..7289d078 100644 --- a/apps/backend/lambdas/reports/controllers/reports.ts +++ b/apps/backend/lambdas/reports/controllers/reports.ts @@ -100,9 +100,7 @@ export const generateReport: RouteHandler = async ({ event }) => { return json(404, { message: 'Project not found' }); } - // Rendering a PDF/DOCX is the slowest thing this backend does and the most - // likely to hit the 30s lambda timeout, so it is timed separately from the - // request as a whole. + // The slowest path here, and the one nearest the 30s lambda timeout. const renderStartedAt = Date.now(); const kind = { report_type: reportType, file_type: fileType }; diff --git a/shared/lambda-http/src/dispatch.ts b/shared/lambda-http/src/dispatch.ts index 81af8567..b9482b05 100644 --- a/shared/lambda-http/src/dispatch.ts +++ b/shared/lambda-http/src/dispatch.ts @@ -17,14 +17,9 @@ import { ANONYMOUS_AUTH } from './authz'; import { reportError } from './errors'; import type { DispatchOptions, RequestAuth, Route } from './types'; -/** Flipped by the first invocation this container serves. */ let firstInvocation = true; -/** - * The route label attached to metrics and logs. Always a *pattern*, never a - * concrete path — `/donors/42` as a label would give Prometheus one series per - * donor. - */ +/** Labels are route *patterns*; `/donors/42` would be one series per donor. */ const UNMATCHED_ROUTE = 'unmatched'; /** @@ -40,9 +35,8 @@ const UNMATCHED_ROUTE = 'unmatched'; * declared permission, 404 and 500 centrally. A controller that runs has * already cleared its route's gate. * - * Also the one place the backend is instrumented: every request produces a - * duration histogram sample, a counter increment and an access log, and the - * buffered telemetry is flushed before the handler returns. + * Also the one place the backend is instrumented: metrics, the access log, and + * the flush that has to happen before Lambda freezes the container. */ export async function dispatch( event: any, @@ -131,12 +125,7 @@ async function route( return json(404, { message: 'Not Found', path, method }); } -/** - * Record the request and ship everything buffered. - * - * Lambda freezes the container as soon as the handler returns, so a flush that - * does not happen here never happens at all. - */ +/** Lambda freezes on return, so a flush that misses here never happens. */ async function complete( context: RequestContext, response: APIGatewayProxyResult, @@ -148,8 +137,7 @@ async function complete( recordRequest({ method: context.method, route, statusCode, durationMs }); - // Health checks are the majority of traffic on an idle stack; counting them is - // useful, narrating them is not. + // Health checks dominate an idle stack: worth counting, not worth narrating. const level = route.endsWith('/health') ? 'debug' : statusCode >= 500 ? 'error' : 'info'; logger[level]('Request served', { 'http.response.status_code': statusCode, @@ -196,7 +184,6 @@ async function enforce( return { auth }; } -/** The caller's `branch.users` id, for the access log. Anonymous routes have none. */ function userIdOf(auth: RequestAuth): string | undefined { const user = auth.context.user; const id = user?.dbUser?.userId ?? user?.userId; diff --git a/shared/lambda-http/src/errors.ts b/shared/lambda-http/src/errors.ts index 6c33eb87..27101d48 100644 --- a/shared/lambda-http/src/errors.ts +++ b/shared/lambda-http/src/errors.ts @@ -29,9 +29,6 @@ export function reportError(err: unknown, context?: Record): vo * catch — the status the caller sees is unchanged, but the error stops being * invisible. `body` merges extra fields into the response for the handful of * routes that already return more than a message. - * - * The log line goes to Loki as well as CloudWatch, carrying the request id and - * route from the enclosing `dispatch`. */ export function serverError( err: unknown, diff --git a/shared/lambda-telemetry/src/config.ts b/shared/lambda-telemetry/src/config.ts index 0ba33f75..84ad3730 100644 --- a/shared/lambda-telemetry/src/config.ts +++ b/shared/lambda-telemetry/src/config.ts @@ -13,13 +13,7 @@ export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; const LEVELS: LogLevel[] = ['debug', 'info', 'warn', 'error']; -/** - * Parse the OTLP env vars Terraform sets on every lambda. - * - * Returns `null` when there is nowhere to export to — local dev, tests, and any - * preview stack that never got the vars. Callers treat that as "no SDK", not as - * an error, so the OTel packages are never even required. - */ +/** `null` when there is no endpoint; callers then never load the SDK. */ export function readConfig(env: NodeJS.ProcessEnv = process.env): TelemetryConfig | null { const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim(); if (!endpoint || env.OTEL_SDK_DISABLED === 'true') return null; @@ -33,7 +27,6 @@ export function readConfig(env: NodeJS.ProcessEnv = process.env): TelemetryConfi }; } -/** Resource identity, which is also useful when the SDK is off (stdout logs carry it). */ export function identity(env: NodeJS.ProcessEnv = process.env) { return { serviceName: env.OTEL_SERVICE_NAME || env.AWS_LAMBDA_FUNCTION_NAME || 'branch-local', @@ -70,9 +63,7 @@ function safeDecode(value: string): string { function parseLevel(raw: string | undefined, nodeEnv?: string): LogLevel { const level = raw?.trim().toLowerCase() as LogLevel | undefined; if (level && LEVELS.includes(level)) return level; - // Jest sets NODE_ENV=test. An access log per request is the point in prod and - // pure noise in a test run, so default it down rather than make every lambda's - // jest config say so. + // Jest sets NODE_ENV=test, where a per-request access log is pure noise. return nodeEnv === 'test' ? 'error' : 'info'; } diff --git a/shared/lambda-telemetry/src/context.ts b/shared/lambda-telemetry/src/context.ts index 3928f6be..68b923f2 100644 --- a/shared/lambda-telemetry/src/context.ts +++ b/shared/lambda-telemetry/src/context.ts @@ -1,12 +1,12 @@ import { AsyncLocalStorage } from 'node:async_hooks'; -/** Fields every log line in a request carries, so a Loki query can follow one request. */ +/** Attached to every log line in the request, so Loki can follow one call. */ export interface RequestContext { requestId?: string; service: string; method: string; path: string; - /** The matched route *pattern* (`/donors/:id`), never the concrete path — Prometheus label cardinality. */ + /** Route *pattern*, never the concrete path — label cardinality. */ route?: string; userId?: string; coldStart: boolean; @@ -22,7 +22,6 @@ export function currentRequestContext(): RequestContext | undefined { return storage.getStore(); } -/** Fill in what is only known part-way through a request (the route, the caller). */ export function enrichRequestContext(fields: Partial): void { const context = storage.getStore(); if (context) Object.assign(context, fields); diff --git a/shared/lambda-telemetry/src/db.ts b/shared/lambda-telemetry/src/db.ts index 5aa8959f..dd8de9c6 100644 --- a/shared/lambda-telemetry/src/db.ts +++ b/shared/lambda-telemetry/src/db.ts @@ -9,16 +9,9 @@ interface QueryLogEvent { error?: unknown; } -/** Queries slower than this get their own log line. */ const SLOW_QUERY_MS = 500; -/** - * Kysely `log` hook: one duration histogram per statement kind, plus a line for - * anything slow or failed. - * - * Wire it in a lambda's `db.ts`: - * `new Kysely({ dialect, log: kyselyTelemetryLog })` - */ +/** Kysely `log` hook: `new Kysely({ dialect, log: kyselyTelemetryLog })`. */ export function kyselyTelemetryLog(event: QueryLogEvent): void { const operation = statementKind(event.query?.sql); const durationMs = Math.round(event.queryDurationMillis ?? 0); @@ -33,10 +26,7 @@ export function kyselyTelemetryLog(event: QueryLogEvent): void { } } -/** - * The leading keyword, which is low-cardinality. The SQL itself is deliberately - * never a label: it embeds ids and would blow up the series count. - */ +/** The leading keyword only. The SQL embeds ids and would blow up cardinality. */ function statementKind(sql: string | undefined): string { const word = sql?.trimStart().split(/\s+/, 1)[0]?.toUpperCase(); return word && /^[A-Z]+$/.test(word) ? word : 'UNKNOWN'; diff --git a/shared/lambda-telemetry/src/logger.ts b/shared/lambda-telemetry/src/logger.ts index 999cfa17..d50da704 100644 --- a/shared/lambda-telemetry/src/logger.ts +++ b/shared/lambda-telemetry/src/logger.ts @@ -18,11 +18,8 @@ export interface LogFields { } /** - * Write one structured line. - * - * Goes to stdout as JSON (so CloudWatch keeps working, and so a lambda with no - * OTLP endpoint still logs) *and* to Loki through the OTel logs exporter. The - * enclosing request's id, route and caller are attached automatically. + * One JSON line to stdout (CloudWatch) and one record to Loki. The enclosing + * request's id, route and caller are attached automatically. */ export function log(level: LogLevel, message: string, fields: LogFields = {}): void { if (levelRank(level) < minLevel()) return; diff --git a/shared/lambda-telemetry/src/metrics.ts b/shared/lambda-telemetry/src/metrics.ts index ad434294..755f892a 100644 --- a/shared/lambda-telemetry/src/metrics.ts +++ b/shared/lambda-telemetry/src/metrics.ts @@ -3,10 +3,7 @@ import { getMeter } from './provider'; export type Attributes = Record; -/** - * Domain metric names. Kept in one table so a dashboard and the code that feeds - * it cannot drift, and so nobody invents a second spelling of the same series. - */ +/** One table, so a dashboard and the code feeding it cannot drift. */ export const METRICS = { LOGIN: 'branch.auth.logins', REGISTRATION: 'branch.auth.registrations', @@ -41,8 +38,7 @@ let boundMeter: Meter | null = null; function meter(): Meter | null { const current = getMeter(); - // A reset (tests) or a late init hands back a different meter; the cached - // instruments belong to the old one. + // A different meter (reset, or late init) orphans the cached instruments. if (current !== boundMeter) { counters.clear(); histograms.clear(); @@ -85,10 +81,7 @@ function clean(attributes: Attributes = {}): Record void): void { try { fn(); @@ -120,7 +113,6 @@ export function recordColdStart(): void { guard(() => counter('faas.cold_starts', 'Lambda cold starts')?.add(1)); } -/** `reason` is `unauthenticated` or `forbidden` — the two gates in `dispatch`. */ export function recordAuthFailure(method: string, route: string, reason: string): void { guard(() => counter('http.server.auth_failures', 'Requests refused by the auth gate')?.add(1, { diff --git a/shared/lambda-telemetry/src/provider.ts b/shared/lambda-telemetry/src/provider.ts index 6e5400db..5d3987fe 100644 --- a/shared/lambda-telemetry/src/provider.ts +++ b/shared/lambda-telemetry/src/provider.ts @@ -15,7 +15,7 @@ let config: TelemetryConfig | null | undefined; const FLUSH_TIMEOUT_MS = 2_000; const EXPORT_TIMEOUT_MS = 5_000; -/** How long the exporters batch before shipping on their own. We force-flush per invocation. */ +/** Rarely reached: every invocation force-flushes first. */ const BATCH_INTERVAL_MS = 60_000; export function telemetryConfig(): TelemetryConfig | null { @@ -23,12 +23,7 @@ export function telemetryConfig(): TelemetryConfig | null { return config; } -/** - * Build the OTel providers on first use, or return `null` forever if there is no - * endpoint. The SDK is `require`d lazily so an unconfigured lambda never pays to - * load it, and an SDK that throws on init degrades to stdout-only logging rather - * than taking the request with it. - */ +/** Lazily `require`s the SDK, so an unconfigured lambda never pays to load it. */ function getProviders(): Providers | null { if (providers !== undefined) return providers; @@ -74,8 +69,7 @@ function build(cfg: TelemetryConfig): Providers { resource, readers: [ new PeriodicExportingMetricReader({ - // Lambda instances are short-lived, so cumulative counters would look - // like a reset storm to Mimir. Delta lets the gateway do the summing. + // Cumulative counters from short-lived containers read as a reset storm to Mimir. exporter: new OTLPMetricExporter({ url: cfg.metricsUrl, headers: cfg.headers, @@ -118,11 +112,8 @@ export function getOtelLogger(): OtelLogger | null { } /** - * Ship whatever is buffered. Lambda freezes the instance the moment the handler - * returns, so nothing that is not flushed here is ever seen. - * - * Never rejects and never blocks longer than {@link FLUSH_TIMEOUT_MS}: Grafana - * being slow must not turn into a 30s lambda timeout. + * Lambda freezes on return, so anything not flushed here is never seen. Never + * rejects, and capped so a slow Grafana cannot become a 30s lambda timeout. */ export async function flushTelemetry(): Promise { const active = providers; @@ -143,7 +134,7 @@ export async function flushTelemetry(): Promise { } } -/** Tests only — drops the memoised providers so a new env can be picked up. */ +/** Tests only. */ export function resetTelemetryForTests(): void { providers = undefined; config = undefined; diff --git a/shared/lambda-telemetry/test/provider.test.ts b/shared/lambda-telemetry/test/provider.test.ts index af716d50..4b8e11e5 100644 --- a/shared/lambda-telemetry/test/provider.test.ts +++ b/shared/lambda-telemetry/test/provider.test.ts @@ -22,8 +22,7 @@ describe('providers, unconfigured', () => { }); describe('providers, configured', () => { - // Exercises the real OTel constructors: an SDK API change shows up here as a - // null meter rather than as silently missing metrics in production. + // Real OTel constructors: an SDK API change fails here, not silently in prod. it('builds a meter and a logger from the OTLP env vars', () => { process.env.OTEL_EXPORTER_OTLP_ENDPOINT = 'http://127.0.0.1:4318'; process.env.OTEL_EXPORTER_OTLP_HEADERS = 'Authorization=Basic%20abc'; From 8c26c1562bb2551ebd4fe5187b344ac1362db2f5 Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Mon, 7 Sep 2026 00:49:59 -0400 Subject: [PATCH 3/4] chore: trim comments Drop the added comments that only restate the code, and collapse the last three multi-line blocks. What survives is the non-obvious part: label cardinality rules, the delta-temporality rationale, the tri-state provider sentinel, and the OTLP header format. Co-Authored-By: Claude Opus 5 (1M context) --- apps/backend/lambdas/auth/db.ts | 1 - apps/backend/lambdas/donors/db.ts | 1 - apps/backend/lambdas/expenditures/db.ts | 1 - apps/backend/lambdas/projects/db.ts | 1 - apps/backend/lambdas/reports/db.ts | 1 - apps/backend/lambdas/users/db.ts | 1 - shared/lambda-http/src/dispatch.ts | 3 +-- shared/lambda-telemetry/src/context.ts | 1 - shared/lambda-telemetry/src/db.ts | 1 - shared/lambda-telemetry/src/logger.ts | 6 +----- shared/lambda-telemetry/src/metrics.ts | 4 ---- shared/lambda-telemetry/src/provider.ts | 6 +----- 12 files changed, 3 insertions(+), 24 deletions(-) diff --git a/apps/backend/lambdas/auth/db.ts b/apps/backend/lambdas/auth/db.ts index 423e9dcb..5e8abee5 100644 --- a/apps/backend/lambdas/auth/db.ts +++ b/apps/backend/lambdas/auth/db.ts @@ -5,7 +5,6 @@ import type { DB } from '@branch/types' import { kyselyTelemetryLog } from '@branch/lambda-telemetry' const db = new Kysely({ - // Query duration + slow/failed query logs. See @branch/lambda-telemetry. log: kyselyTelemetryLog, dialect: new PostgresDialect({ pool: new Pool({ diff --git a/apps/backend/lambdas/donors/db.ts b/apps/backend/lambdas/donors/db.ts index 852f5bdf..6a696216 100644 --- a/apps/backend/lambdas/donors/db.ts +++ b/apps/backend/lambdas/donors/db.ts @@ -5,7 +5,6 @@ import { kyselyTelemetryLog } from '@branch/lambda-telemetry' const db = new Kysely({ - // Query duration + slow/failed query logs. See @branch/lambda-telemetry. log: kyselyTelemetryLog, dialect: new PostgresDialect({ pool: new Pool({ diff --git a/apps/backend/lambdas/expenditures/db.ts b/apps/backend/lambdas/expenditures/db.ts index af7764f6..3dee5e73 100644 --- a/apps/backend/lambdas/expenditures/db.ts +++ b/apps/backend/lambdas/expenditures/db.ts @@ -5,7 +5,6 @@ import { kyselyTelemetryLog } from '@branch/lambda-telemetry' const db = new Kysely({ - // Query duration + slow/failed query logs. See @branch/lambda-telemetry. log: kyselyTelemetryLog, dialect: new PostgresDialect({ pool: new Pool({ diff --git a/apps/backend/lambdas/projects/db.ts b/apps/backend/lambdas/projects/db.ts index e1c400ce..7a740465 100644 --- a/apps/backend/lambdas/projects/db.ts +++ b/apps/backend/lambdas/projects/db.ts @@ -4,7 +4,6 @@ import type { DB } from '@branch/types' import { kyselyTelemetryLog } from '@branch/lambda-telemetry' const db = new Kysely({ - // Query duration + slow/failed query logs. See @branch/lambda-telemetry. log: kyselyTelemetryLog, dialect: new PostgresDialect({ pool: new Pool({ diff --git a/apps/backend/lambdas/reports/db.ts b/apps/backend/lambdas/reports/db.ts index b30a3a47..87107a52 100644 --- a/apps/backend/lambdas/reports/db.ts +++ b/apps/backend/lambdas/reports/db.ts @@ -4,7 +4,6 @@ import type { DB } from '@branch/types' import { kyselyTelemetryLog } from '@branch/lambda-telemetry' const db = new Kysely({ - // Query duration + slow/failed query logs. See @branch/lambda-telemetry. log: kyselyTelemetryLog, dialect: new PostgresDialect({ pool: new Pool({ diff --git a/apps/backend/lambdas/users/db.ts b/apps/backend/lambdas/users/db.ts index 423e9dcb..5e8abee5 100644 --- a/apps/backend/lambdas/users/db.ts +++ b/apps/backend/lambdas/users/db.ts @@ -5,7 +5,6 @@ import type { DB } from '@branch/types' import { kyselyTelemetryLog } from '@branch/lambda-telemetry' const db = new Kysely({ - // Query duration + slow/failed query logs. See @branch/lambda-telemetry. log: kyselyTelemetryLog, dialect: new PostgresDialect({ pool: new Pool({ diff --git a/shared/lambda-http/src/dispatch.ts b/shared/lambda-http/src/dispatch.ts index b9482b05..1f4b23b7 100644 --- a/shared/lambda-http/src/dispatch.ts +++ b/shared/lambda-http/src/dispatch.ts @@ -35,8 +35,7 @@ const UNMATCHED_ROUTE = 'unmatched'; * declared permission, 404 and 500 centrally. A controller that runs has * already cleared its route's gate. * - * Also the one place the backend is instrumented: metrics, the access log, and - * the flush that has to happen before Lambda freezes the container. + * Also the one place the backend is instrumented. */ export async function dispatch( event: any, diff --git a/shared/lambda-telemetry/src/context.ts b/shared/lambda-telemetry/src/context.ts index 68b923f2..92d0116b 100644 --- a/shared/lambda-telemetry/src/context.ts +++ b/shared/lambda-telemetry/src/context.ts @@ -1,6 +1,5 @@ import { AsyncLocalStorage } from 'node:async_hooks'; -/** Attached to every log line in the request, so Loki can follow one call. */ export interface RequestContext { requestId?: string; service: string; diff --git a/shared/lambda-telemetry/src/db.ts b/shared/lambda-telemetry/src/db.ts index dd8de9c6..1d771a7f 100644 --- a/shared/lambda-telemetry/src/db.ts +++ b/shared/lambda-telemetry/src/db.ts @@ -11,7 +11,6 @@ interface QueryLogEvent { const SLOW_QUERY_MS = 500; -/** Kysely `log` hook: `new Kysely({ dialect, log: kyselyTelemetryLog })`. */ export function kyselyTelemetryLog(event: QueryLogEvent): void { const operation = statementKind(event.query?.sql); const durationMs = Math.round(event.queryDurationMillis ?? 0); diff --git a/shared/lambda-telemetry/src/logger.ts b/shared/lambda-telemetry/src/logger.ts index d50da704..17154d3f 100644 --- a/shared/lambda-telemetry/src/logger.ts +++ b/shared/lambda-telemetry/src/logger.ts @@ -17,10 +17,7 @@ export interface LogFields { [field: string]: unknown; } -/** - * One JSON line to stdout (CloudWatch) and one record to Loki. The enclosing - * request's id, route and caller are attached automatically. - */ +/** One line to stdout (CloudWatch) and one record to Loki. */ export function log(level: LogLevel, message: string, fields: LogFields = {}): void { if (levelRank(level) < minLevel()) return; @@ -106,7 +103,6 @@ function describeError(err: unknown): Record { return { 'error.type': typeof err, 'error.message': String(err) }; } -/** Tests only — re-reads `LOG_LEVEL`. */ export function resetLoggerForTests(): void { threshold = undefined; } diff --git a/shared/lambda-telemetry/src/metrics.ts b/shared/lambda-telemetry/src/metrics.ts index 755f892a..389cc846 100644 --- a/shared/lambda-telemetry/src/metrics.ts +++ b/shared/lambda-telemetry/src/metrics.ts @@ -3,7 +3,6 @@ import { getMeter } from './provider'; export type Attributes = Record; -/** One table, so a dashboard and the code feeding it cannot drift. */ export const METRICS = { LOGIN: 'branch.auth.logins', REGISTRATION: 'branch.auth.registrations', @@ -81,7 +80,6 @@ function clean(attributes: Attributes = {}): Record void): void { try { fn(); @@ -141,12 +139,10 @@ export function recordDbQuery(operation: string, durationMs: number, ok: boolean ); } -/** Count a domain event. Use a {@link METRICS} constant, not a literal. */ export function recordEvent(name: string, attributes?: Attributes): void { guard(() => counter(name, name)?.add(1, clean(attributes))); } -/** Record a domain measurement (money, duration). Use a {@link METRICS} constant. */ export function recordValue(name: string, value: number, attributes?: Attributes): void { if (!Number.isFinite(value)) return; guard(() => histogram(name)?.record(value, clean(attributes))); diff --git a/shared/lambda-telemetry/src/provider.ts b/shared/lambda-telemetry/src/provider.ts index 5d3987fe..ec3b0e2c 100644 --- a/shared/lambda-telemetry/src/provider.ts +++ b/shared/lambda-telemetry/src/provider.ts @@ -111,10 +111,7 @@ export function getOtelLogger(): OtelLogger | null { return getProviders()?.otelLogger ?? null; } -/** - * Lambda freezes on return, so anything not flushed here is never seen. Never - * rejects, and capped so a slow Grafana cannot become a 30s lambda timeout. - */ +/** Capped: a slow Grafana must not become a 30s lambda timeout. */ export async function flushTelemetry(): Promise { const active = providers; if (!active) return; @@ -134,7 +131,6 @@ export async function flushTelemetry(): Promise { } } -/** Tests only. */ export function resetTelemetryForTests(): void { providers = undefined; config = undefined; From 3d77906cef4462712749186b5de526ba68a75dee Mon Sep 17 00:00:00 2001 From: Nour Shoreibah <168875317+nourshoreibah@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:45:41 -0400 Subject: [PATCH 4/4] Delete docs/superpowers/specs/2026-09-06-grafana-telemetry-design.md --- .../2026-09-06-grafana-telemetry-design.md | 119 ------------------ 1 file changed, 119 deletions(-) delete mode 100644 docs/superpowers/specs/2026-09-06-grafana-telemetry-design.md diff --git a/docs/superpowers/specs/2026-09-06-grafana-telemetry-design.md b/docs/superpowers/specs/2026-09-06-grafana-telemetry-design.md deleted file mode 100644 index 9fba5c0f..00000000 --- a/docs/superpowers/specs/2026-09-06-grafana-telemetry-design.md +++ /dev/null @@ -1,119 +0,0 @@ -# Sending metrics and logs to Grafana Cloud - -**Date:** 2026-09-06 -**Status:** implemented on `worktree-grafana-otel-telemetry` - -## Problem - -Grafana Cloud is provisioned and the lambdas have carried -`OTEL_EXPORTER_OTLP_ENDPOINT` and `OTEL_EXPORTER_OTLP_HEADERS` since commit -`2dacc6d`, but no code in the repo ever read them. The account receives nothing. - -What observability exists today: Sentry (uncaught throws, via a Lambda layer) -and unstructured `console.*` in CloudWatch with 30-day retention. There are no -CloudWatch alarms, no dashboards, no X-Ray. So there is no way to answer "is the -API slow", "which endpoint is erroring", "how long does a report take", or "did -that failed login spike matter". - -## Decisions - -Two choices drove the design. Both were taken as the recommended defaults; -they are recorded here as assumptions rather than as a user ruling. - -1. **Bundled OTel SDK in a shared package**, not an ADOT/OTel Lambda layer and - not CloudWatch EMF. It uses the OTLP endpoint that is already configured and - paid for, needs **no Terraform change at all**, and gives full control over - domain metrics. The layer route would have meant a Terraform change plus a - collector config and ~1s of extra cold start; EMF would have ignored the OTLP - endpoint and required a Grafana→AWS IAM role. - -2. **Metrics and structured logs, not traces.** Sentry already covers exception - detail, and traces would roughly double the bundle for overlapping value. - Traces remain easy to add later — the provider module is the only place that - would change. - -## Architecture - -One new `file:`-linked package, `@branch/lambda-telemetry`, plus instrumentation -at the two places every request already passes through. - -``` -shared/lambda-telemetry/ - config.ts env parsing; returns null when there is no endpoint - provider.ts lazy OTel MeterProvider + LoggerProvider, forceFlush - metrics.ts instrument registry + recorders; the METRICS name table - logger.ts structured logger -> stdout JSON *and* an OTel log record - context.ts AsyncLocalStorage request context - db.ts the Kysely `log` hook -``` - -**`dispatch()` is the choke point.** All six services route through it, so -instrumenting it once covers every endpoint: duration histogram, request -counter, cold starts, auth refusals (401 vs 403), unhandled errors, and one -access log per request. Controllers add only domain metrics. - -**`db.ts` is the second choke point.** `log: kyselyTelemetryLog` on each -lambda's Kysely instance measures every statement and logs the slow and failed -ones. - -### Signals - -Automatic, every route: - -| Metric | Kind | Labels | -|---|---|---| -| `http.server.requests` | counter | method, route, status | -| `http.server.request.duration` | histogram (ms) | method, route, status | -| `faas.cold_starts` | counter | — | -| `http.server.auth_failures` | counter | method, route, reason | -| `http.server.unhandled_errors` | counter | method, route | -| `db.client.operation.duration` | histogram (ms) | statement kind, outcome | - -Domain, recorded explicitly: - -| Metric | Where | Why it earns its place | -|---|---|---| -| `branch.auth.logins` | auth | A failure spike is the credential-stuffing signal | -| `branch.auth.registrations` | auth | `invitation_required` shows people bouncing off the invite gate | -| `branch.users.invited` | users | Onboarding throughput | -| `branch.projects.changed` | projects | Deletes cascade into expenditures, reports and S3 | -| `branch.expenditures.changed` | expenditures | The approval funnel: approved / denied / needs_more_info | -| `branch.expenditures.amount` | expenditures | Spend distribution by category | -| `branch.donations.recorded` / `.amount` | donors | Donation rate and size — the number the org actually cares about | -| `branch.reports.generated` | reports | Success vs render-failed vs upload-failed | -| `branch.reports.generation.duration` / `.size` | reports | The slowest path in the backend, and the one nearest the 30s timeout | - -Logs: one JSON line per `logger.*` call, to stdout **and** to Loki, carrying -request id, service, route, method, path, cold-start flag, user id, plus the -call's own fields. `serverError()` routes through it, so every 500 is -structured. An `Error` is flattened rather than serialised to `{}`. - -## Constraints honoured - -- **Telemetry never breaks a request.** Every recorder, the logger and the flush - swallow their own failures. -- **No unbounded labels.** Route patterns, never concrete paths; statement kind, - never SQL; no ids, no email addresses. -- **Flush before the freeze.** `dispatch()` flushes in a `finally`, capped at 2s - so a slow Grafana cannot become a 30s lambda timeout. -- **Delta temporality.** Cumulative counters from short-lived Lambda containers - read as a reset storm to Mimir. -- **Off is a valid state.** No endpoint means the OTel SDK is never `require`d, - so local dev and tests neither export nor pay the load cost. -- **Quiet in tests.** `NODE_ENV=test` defaults `LOG_LEVEL` to `error`, so the - access log does not drown jest output. - -## Cost - -The OTel SDK adds 245 KB minified to each bundle (528 KB total, 136 KB zipped). -Per invocation: buffered records plus one flush, bounded at 2s. - -## Not in scope - -- Traces / Tempo. -- Terraform changes. Preview stacks inherit the OTLP vars because - `preview-env.yml` copies the live lambda environment, so their telemetry - arrives tagged `pr-` — the same treatment their Sentry errors get. -- Dashboards and alert rules as code. The metric names above are the contract a - dashboard would build on. -- The frontend. Grafana Faro would be a separate piece of work.