@dwk/log 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 David W. Keith
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,122 @@
1
+ # `@dwk/log`
2
+
3
+ Minimal, injectable structured-logging seam shared across the `@dwk` packages.
4
+
5
+ A **cross-standard reusable lib** (like [`@dwk/dpop`](../dpop) and
6
+ [`@dwk/rdf`](../rdf)): protocol-agnostic, stateless, and unit-testable without a
7
+ Workers runtime. It defines _where_ the `@dwk` packages send signal, not _how_
8
+ that signal is stored — the composed Worker wires a concrete logger to Workers
9
+ structured logs / Logpush / Analytics Engine.
10
+
11
+ See [`spec/observability.md`](../../spec/observability.md) for the cross-cutting
12
+ requirement and the event-taxonomy conventions.
13
+
14
+ ## Why
15
+
16
+ The `@dwk` packages handle untrusted, attacker-supplied input. Without a logging
17
+ seam, security-relevant events — a blocked SSRF attempt, an auth rejection, a
18
+ poison queue message — are silently swallowed and indistinguishable from a dead
19
+ link or a timeout. This package is the seam those events flow through.
20
+
21
+ ## The seam
22
+
23
+ ```ts
24
+ import type { Logger } from "@dwk/log";
25
+
26
+ export interface Logger {
27
+ debug(event: string, fields?: Record<string, unknown>): void;
28
+ info(event: string, fields?: Record<string, unknown>): void;
29
+ warn(event: string, fields?: Record<string, unknown>): void;
30
+ error(event: string, fields?: Record<string, unknown>): void;
31
+ }
32
+ ```
33
+
34
+ Each call names a **stable, dotted event** (e.g. `webmention.ssrf.blocked`) plus
35
+ a flat bag of structured fields, so operators query by event and field instead
36
+ of grepping prose. Event-name taxonomies are owned by each consuming package.
37
+
38
+ ## Usage
39
+
40
+ A package accepts an **optional** `logger` in its config and defaults to
41
+ `noopLogger`, so logging is strictly opt-in:
42
+
43
+ ```ts
44
+ import { noopLogger, type Logger } from "@dwk/log";
45
+
46
+ function createThing(config: { logger?: Logger }) {
47
+ const logger = config.logger ?? noopLogger;
48
+ logger.warn("thing.blocked", { reason: "policy" });
49
+ }
50
+ ```
51
+
52
+ The composed Worker wires a real logger once:
53
+
54
+ ```ts
55
+ import { consoleLogger } from "@dwk/log";
56
+
57
+ const logger = consoleLogger({ minLevel: "info", base: { service: "wm" } });
58
+ const handler = createWebmention({ baseUrl, logger });
59
+ ```
60
+
61
+ ### Exports
62
+
63
+ | Export | Purpose |
64
+ | ---------------------------- | -------------------------------------------------------------- |
65
+ | `Logger`, `LogLevel`, `LogFields` | The logging seam types. |
66
+ | `noopLogger` | Discards everything; the default when no logger is configured. |
67
+ | `consoleLogger(options?)` | Emits one JSON record per call to `console` (Workers logs). |
68
+ | `withContext(logger, ctx)` | Binds request/pod-scoped fields onto every record. |
69
+ | `hostFromUrl(raw)` | Redaction helper: a URL's host only, never its path/query. |
70
+ | `Metrics` | The metrics seam type (`count` / `observe`). |
71
+ | `noopMetrics` | Discards everything; the default when no metrics sink is set. |
72
+ | `analyticsEngineMetrics(dataset, options?)` | Adapter to Cloudflare Workers Analytics Engine. |
73
+
74
+ ## Metrics
75
+
76
+ The companion **metrics** seam answers "how often / how much?" for the same
77
+ events the logger names, so an operator can chart "SSRF blocks/min" or
78
+ "verification success rate" instead of scraping log lines. It is injected the
79
+ same way — an **optional** `metrics`, defaulting to `noopMetrics` — and reuses
80
+ the same event names and field bags as logs, so logs and counters share one
81
+ vocabulary:
82
+
83
+ ```ts
84
+ import { noopMetrics, type Metrics } from "@dwk/log";
85
+
86
+ function createThing(config: { metrics?: Metrics }) {
87
+ const metrics = config.metrics ?? noopMetrics;
88
+ metrics.count("thing.blocked", { reason: "policy" }); // a counter
89
+ metrics.observe("thing.latency", 42, { host: "a.example" }); // an observation
90
+ }
91
+ ```
92
+
93
+ The composed Worker wires the real adapter once, from a bound
94
+ `AnalyticsEngineDataset`:
95
+
96
+ ```ts
97
+ import { analyticsEngineMetrics } from "@dwk/log";
98
+
99
+ // env.WM_METRICS is an AnalyticsEngineDataset binding declared in wrangler.toml.
100
+ const metrics = analyticsEngineMetrics(env.WM_METRICS, {
101
+ base: { service: "wm" },
102
+ });
103
+ const handler = createWebmention({ baseUrl, logger, metrics });
104
+ ```
105
+
106
+ `analyticsEngineMetrics` maps each call onto `writeDataPoint` deterministically:
107
+ the `event` becomes `indexes[0]` (the sampling key) and `blobs[0]`; string fields
108
+ become further `blobs`, and numeric/boolean fields become `doubles` (with a lead
109
+ `1` for `count` or the observed value for `observe`) — all in sorted key order so
110
+ positions are stable per event. Cloudflare's Analytics Engine limits (1 index ≤
111
+ 96 B, ≤ 20 blobs ≤ 16 KB total, ≤ 20 doubles) are enforced, and like `Logger` a
112
+ `Metrics` implementation **never throws** into the operation it measures. The
113
+ binding type is declared structurally (`AnalyticsEngineDatasetLike`), so this
114
+ package keeps no `@cloudflare/workers-types` dependency. The same redaction rules
115
+ apply — never pass tokens, bodies, or full URLs as fields.
116
+
117
+ ## Redaction
118
+
119
+ Redaction is the caller's responsibility, but the seam helps. **Never** pass
120
+ tokens, credentials, or full request/response bodies as fields. For URLs, prefer
121
+ `hostFromUrl(raw)` so an attacker-supplied path or query string never lands in a
122
+ log line.
@@ -0,0 +1,121 @@
1
+ /**
2
+ * `@dwk/log` — a minimal, injectable structured-logging seam.
3
+ *
4
+ * The `@dwk` packages handle untrusted, attacker-supplied input, so failures and
5
+ * security-relevant events (a blocked SSRF attempt, an auth rejection) must
6
+ * produce a signal rather than being silently swallowed. This library defines
7
+ * the seam that signal flows through. It deliberately does **no I/O of its own
8
+ * by default**: a package takes an optional {@link Logger} in its config and
9
+ * calls it; the composed Worker decides where the records actually go (Workers
10
+ * structured logs, Logpush, Analytics Engine, …) by wiring in a concrete logger.
11
+ *
12
+ * It is a **cross-standard reusable**: like `@dwk/dpop` and `@dwk/rdf`, it MUST
13
+ * stay free of IndieWeb/Solid assumptions so future `@dwk` standards adopt it
14
+ * unchanged. It holds no state, performs no protocol logic, and unit-tests
15
+ * without a Workers runtime.
16
+ *
17
+ * Logs are **structured events**, not free text: every call names a stable,
18
+ * dotted `event` (e.g. `webmention.ssrf.blocked`) plus a flat bag of fields, so
19
+ * an operator can query by event and field rather than grep prose. Event-name
20
+ * taxonomies are owned by each consuming package, not by this library.
21
+ *
22
+ * **Redaction is the caller's responsibility, but the seam helps:** never pass
23
+ * tokens, credentials, or full request/response bodies as fields. For URLs,
24
+ * prefer {@link hostFromUrl} so an attacker-supplied path/query never lands in a
25
+ * log line.
26
+ *
27
+ * Logs answer "what happened?"; the companion {@link Metrics} seam (same file
28
+ * family, same injection discipline) answers "how often / how much?" for the
29
+ * same events. See {@link ./metrics}.
30
+ *
31
+ * @see spec/observability.md
32
+ * @see spec/packages/log.md
33
+ * @packageDocumentation
34
+ */
35
+ export { type Metrics, noopMetrics, analyticsEngineMetrics, type AnalyticsEngineDatasetLike, type AnalyticsEngineDataPoint, type AnalyticsEngineMetricsOptions, } from "./metrics";
36
+ /** Severity of a log record, in increasing order of importance. */
37
+ export type LogLevel = "debug" | "info" | "warn" | "error";
38
+ /**
39
+ * A flat bag of structured fields attached to a log record. Keys SHOULD be
40
+ * stable and queryable; values MUST be safe to record (no secrets, no bodies).
41
+ */
42
+ export type LogFields = Record<string, unknown>;
43
+ /**
44
+ * The logging seam every `@dwk` package writes to.
45
+ *
46
+ * Each method takes a stable, dotted `event` name and an optional bag of
47
+ * structured `fields`. Implementations MUST NOT throw — a logging failure must
48
+ * never break the operation being logged.
49
+ */
50
+ export interface Logger {
51
+ /** Verbose, developer-facing detail; off in production by default. */
52
+ debug(event: string, fields?: LogFields): void;
53
+ /** A normal, noteworthy outcome (e.g. a verification completed). */
54
+ info(event: string, fields?: LogFields): void;
55
+ /** A handled-but-notable event (e.g. a blocked SSRF attempt, a retry). */
56
+ warn(event: string, fields?: LogFields): void;
57
+ /** A failure that needs attention. */
58
+ error(event: string, fields?: LogFields): void;
59
+ }
60
+ /**
61
+ * A logger that discards everything. This is the default a package uses when
62
+ * its config supplies none, so logging is strictly opt-in and pure libs stay
63
+ * silent and side-effect-free in tests.
64
+ */
65
+ export declare const noopLogger: Logger;
66
+ /**
67
+ * The slice of the global `console` (or any compatible sink) that
68
+ * {@link consoleLogger} writes to. Declared structurally so a test can pass a
69
+ * spy and the composed Worker can pass `console`.
70
+ */
71
+ export interface ConsoleLike {
72
+ debug(...args: unknown[]): void;
73
+ info(...args: unknown[]): void;
74
+ warn(...args: unknown[]): void;
75
+ error(...args: unknown[]): void;
76
+ }
77
+ /** Tunables for {@link consoleLogger}. */
78
+ export interface ConsoleLoggerOptions {
79
+ /** Sink to write to; defaults to the global `console`. */
80
+ readonly console?: ConsoleLike;
81
+ /** Drop records below this level (default `"debug"`, i.e. emit everything). */
82
+ readonly minLevel?: LogLevel;
83
+ /** Fields merged into every record (e.g. a deployment or request id). */
84
+ readonly base?: LogFields;
85
+ /** Timestamp source, for deterministic tests (default `Date.now` as ISO). */
86
+ readonly now?: () => string;
87
+ }
88
+ /**
89
+ * A {@link Logger} that emits one JSON object per record to `console`, suitable
90
+ * for Cloudflare Workers structured logs / Logpush.
91
+ *
92
+ * Each record is `{ level, event, time, ...base, ...fields }`, serialized with
93
+ * `JSON.stringify` (which drops `undefined`-valued fields, so optional context
94
+ * can be passed unconditionally). Records below `minLevel` are dropped. The
95
+ * matching `console` method is used per level so a platform's level routing
96
+ * still applies.
97
+ *
98
+ * Honors the {@link Logger} non-throwing contract: if a field is not
99
+ * JSON-serializable (a circular reference, a `BigInt`, …) the record is replaced
100
+ * by a minimal `log.serialization_failed` record rather than throwing into the
101
+ * operation being logged.
102
+ */
103
+ export declare function consoleLogger(options?: ConsoleLoggerOptions): Logger;
104
+ /**
105
+ * Wrap `logger` so that `context` is merged into the fields of every record.
106
+ *
107
+ * Use this to bind request- or pod-scoped context (a request id, a pod id) once
108
+ * at the composition boundary instead of repeating it at every call site. The
109
+ * per-call `fields` win on key collisions.
110
+ */
111
+ export declare function withContext(logger: Logger, context: LogFields): Logger;
112
+ /**
113
+ * Extract just the host from a URL string, for safe logging.
114
+ *
115
+ * Returns the `host` (name plus any non-default port) and nothing else — no
116
+ * scheme, path, query, or userinfo — so an attacker-supplied `source`/`target`
117
+ * URL never lands in a log line in full. Returns `undefined` when `raw` is not a
118
+ * parseable URL.
119
+ */
120
+ export declare function hostFromUrl(raw: string): string | undefined;
121
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,OAAO,EACL,KAAK,OAAO,EACZ,WAAW,EACX,sBAAsB,EACtB,KAAK,0BAA0B,EAC/B,KAAK,wBAAwB,EAC7B,KAAK,6BAA6B,GACnC,MAAM,WAAW,CAAC;AAEnB,mEAAmE;AACnE,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAE3D;;;GAGG;AACH,MAAM,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEhD;;;;;;GAMG;AACH,MAAM,WAAW,MAAM;IACrB,sEAAsE;IACtE,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,GAAG,IAAI,CAAC;IAC/C,oEAAoE;IACpE,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,GAAG,IAAI,CAAC;IAC9C,0EAA0E;IAC1E,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,GAAG,IAAI,CAAC;IAC9C,sCAAsC;IACtC,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,GAAG,IAAI,CAAC;CAChD;AAED;;;;GAIG;AACH,eAAO,MAAM,UAAU,EAAE,MAKxB,CAAC;AAUF;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC1B,KAAK,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAChC,IAAI,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAC/B,IAAI,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAC/B,KAAK,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;CACjC;AAED,0CAA0C;AAC1C,MAAM,WAAW,oBAAoB;IACnC,0DAA0D;IAC1D,QAAQ,CAAC,OAAO,CAAC,EAAE,WAAW,CAAC;IAC/B,+EAA+E;IAC/E,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC;IAC7B,yEAAyE;IACzE,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,CAAC;IAC1B,6EAA6E;IAC7E,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CAC7B;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,aAAa,CAAC,OAAO,GAAE,oBAAyB,GAAG,MAAM,CA4CxE;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,GAAG,MAAM,CAQtE;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAM3D"}
package/dist/index.js ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * `@dwk/log` — a minimal, injectable structured-logging seam.
3
+ *
4
+ * The `@dwk` packages handle untrusted, attacker-supplied input, so failures and
5
+ * security-relevant events (a blocked SSRF attempt, an auth rejection) must
6
+ * produce a signal rather than being silently swallowed. This library defines
7
+ * the seam that signal flows through. It deliberately does **no I/O of its own
8
+ * by default**: a package takes an optional {@link Logger} in its config and
9
+ * calls it; the composed Worker decides where the records actually go (Workers
10
+ * structured logs, Logpush, Analytics Engine, …) by wiring in a concrete logger.
11
+ *
12
+ * It is a **cross-standard reusable**: like `@dwk/dpop` and `@dwk/rdf`, it MUST
13
+ * stay free of IndieWeb/Solid assumptions so future `@dwk` standards adopt it
14
+ * unchanged. It holds no state, performs no protocol logic, and unit-tests
15
+ * without a Workers runtime.
16
+ *
17
+ * Logs are **structured events**, not free text: every call names a stable,
18
+ * dotted `event` (e.g. `webmention.ssrf.blocked`) plus a flat bag of fields, so
19
+ * an operator can query by event and field rather than grep prose. Event-name
20
+ * taxonomies are owned by each consuming package, not by this library.
21
+ *
22
+ * **Redaction is the caller's responsibility, but the seam helps:** never pass
23
+ * tokens, credentials, or full request/response bodies as fields. For URLs,
24
+ * prefer {@link hostFromUrl} so an attacker-supplied path/query never lands in a
25
+ * log line.
26
+ *
27
+ * Logs answer "what happened?"; the companion {@link Metrics} seam (same file
28
+ * family, same injection discipline) answers "how often / how much?" for the
29
+ * same events. See {@link ./metrics}.
30
+ *
31
+ * @see spec/observability.md
32
+ * @see spec/packages/log.md
33
+ * @packageDocumentation
34
+ */
35
+ export { noopMetrics, analyticsEngineMetrics, } from "./metrics";
36
+ /**
37
+ * A logger that discards everything. This is the default a package uses when
38
+ * its config supplies none, so logging is strictly opt-in and pure libs stay
39
+ * silent and side-effect-free in tests.
40
+ */
41
+ export const noopLogger = {
42
+ debug: () => { },
43
+ info: () => { },
44
+ warn: () => { },
45
+ error: () => { },
46
+ };
47
+ /** Numeric ordering of {@link LogLevel} used for threshold filtering. */
48
+ const LEVEL_ORDER = {
49
+ debug: 10,
50
+ info: 20,
51
+ warn: 30,
52
+ error: 40,
53
+ };
54
+ /**
55
+ * A {@link Logger} that emits one JSON object per record to `console`, suitable
56
+ * for Cloudflare Workers structured logs / Logpush.
57
+ *
58
+ * Each record is `{ level, event, time, ...base, ...fields }`, serialized with
59
+ * `JSON.stringify` (which drops `undefined`-valued fields, so optional context
60
+ * can be passed unconditionally). Records below `minLevel` are dropped. The
61
+ * matching `console` method is used per level so a platform's level routing
62
+ * still applies.
63
+ *
64
+ * Honors the {@link Logger} non-throwing contract: if a field is not
65
+ * JSON-serializable (a circular reference, a `BigInt`, …) the record is replaced
66
+ * by a minimal `log.serialization_failed` record rather than throwing into the
67
+ * operation being logged.
68
+ */
69
+ export function consoleLogger(options = {}) {
70
+ const sink = options.console ?? console;
71
+ const threshold = LEVEL_ORDER[options.minLevel ?? "debug"];
72
+ const now = options.now ?? (() => new Date().toISOString());
73
+ const base = options.base;
74
+ const emit = (level) => (event, fields) => {
75
+ if (LEVEL_ORDER[level] < threshold) {
76
+ return;
77
+ }
78
+ const record = {
79
+ level,
80
+ event,
81
+ time: now(),
82
+ ...base,
83
+ ...fields,
84
+ };
85
+ try {
86
+ sink[level](JSON.stringify(record));
87
+ }
88
+ catch (err) {
89
+ // Honor the Logger "MUST NOT throw" contract: a non-serializable field
90
+ // (circular reference, BigInt, …) must never break the operation being
91
+ // logged. Fall back to a minimal, guaranteed-serializable record. The
92
+ // serializer's own error message is safe to log — it is not caller data.
93
+ sink.error(JSON.stringify({
94
+ level: "error",
95
+ event: "log.serialization_failed",
96
+ time: record.time,
97
+ failedEvent: event,
98
+ error: err instanceof Error ? err.message : String(err),
99
+ }));
100
+ }
101
+ };
102
+ return {
103
+ debug: emit("debug"),
104
+ info: emit("info"),
105
+ warn: emit("warn"),
106
+ error: emit("error"),
107
+ };
108
+ }
109
+ /**
110
+ * Wrap `logger` so that `context` is merged into the fields of every record.
111
+ *
112
+ * Use this to bind request- or pod-scoped context (a request id, a pod id) once
113
+ * at the composition boundary instead of repeating it at every call site. The
114
+ * per-call `fields` win on key collisions.
115
+ */
116
+ export function withContext(logger, context) {
117
+ const merge = (fields) => ({ ...context, ...fields });
118
+ return {
119
+ debug: (event, fields) => logger.debug(event, merge(fields)),
120
+ info: (event, fields) => logger.info(event, merge(fields)),
121
+ warn: (event, fields) => logger.warn(event, merge(fields)),
122
+ error: (event, fields) => logger.error(event, merge(fields)),
123
+ };
124
+ }
125
+ /**
126
+ * Extract just the host from a URL string, for safe logging.
127
+ *
128
+ * Returns the `host` (name plus any non-default port) and nothing else — no
129
+ * scheme, path, query, or userinfo — so an attacker-supplied `source`/`target`
130
+ * URL never lands in a log line in full. Returns `undefined` when `raw` is not a
131
+ * parseable URL.
132
+ */
133
+ export function hostFromUrl(raw) {
134
+ try {
135
+ return new URL(raw).host;
136
+ }
137
+ catch {
138
+ return undefined;
139
+ }
140
+ }
141
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `@dwk/log` — a minimal, injectable **metrics** seam, companion to {@link Logger}.
3
+ *
4
+ * Where the {@link Logger} answers "what happened?" with one structured record
5
+ * per event, this seam answers "how often / how much?" — emitting queryable
6
+ * counters and observations so an operator can chart "SSRF blocks/min",
7
+ * "verification success rate", or "queue retries by reason" rather than scraping
8
+ * log lines. It mirrors the logging seam exactly: a package takes an **optional**
9
+ * {@link Metrics} in its config, defaulting to {@link noopMetrics}, and the
10
+ * composed Worker wires a concrete adapter **once** at the composition boundary.
11
+ * It does **no I/O of its own by default**.
12
+ *
13
+ * Like the rest of `@dwk/log` it is a **cross-standard reusable**: protocol-
14
+ * agnostic, holding no IndieWeb/Solid assumptions, with no Workers-runtime
15
+ * dependency. The {@link analyticsEngineMetrics} adapter targets Cloudflare
16
+ * Workers Analytics Engine through a **structural** binding type
17
+ * ({@link AnalyticsEngineDatasetLike}) — the same trick {@link ConsoleLike} uses
18
+ * for `console` — so this library never imports `@cloudflare/workers-types` and
19
+ * still unit-tests under plain Node.
20
+ *
21
+ * Metrics reuse the same **event taxonomy** and the same {@link LogFields} bags
22
+ * as logs, so logs and metrics share one vocabulary: pass the same
23
+ * `(event, fields)` to both seams. The **redaction policy is identical** — only
24
+ * hosts, ports, status, machine-readable reason/result codes, booleans, and
25
+ * counts; never tokens, bodies, or full URLs (use {@link hostFromUrl}).
26
+ *
27
+ * @see spec/observability.md
28
+ * @packageDocumentation
29
+ */
30
+ import type { LogFields } from "./index";
31
+ /**
32
+ * The metrics seam every `@dwk` package writes to, alongside {@link Logger}.
33
+ *
34
+ * Both methods name the same stable, dotted `event` as the logger and an
35
+ * optional bag of structured {@link LogFields} — the adapter turns string fields
36
+ * into queryable dimensions and numeric/boolean fields into measurements.
37
+ * Implementations MUST NOT throw — a metrics failure must never break the
38
+ * operation being measured.
39
+ */
40
+ export interface Metrics {
41
+ /**
42
+ * Record one occurrence of `event` (a counter increment of 1). Use for
43
+ * tallies like "SSRF blocks", "mentions received", "retries".
44
+ */
45
+ count(event: string, fields?: LogFields): void;
46
+ /**
47
+ * Record a numeric observation of `value` for `event` (a gauge/histogram
48
+ * sample). Use for measurements like a fetch latency in milliseconds.
49
+ */
50
+ observe(event: string, value: number, fields?: LogFields): void;
51
+ }
52
+ /**
53
+ * A metrics sink that discards everything. This is the default a package uses
54
+ * when its config supplies none, so metrics are strictly opt-in and pure libs
55
+ * stay side-effect-free in tests.
56
+ */
57
+ export declare const noopMetrics: Metrics;
58
+ /**
59
+ * One Analytics Engine data point. A structural mirror of Cloudflare's
60
+ * `AnalyticsEngineDataPoint` so this library needs no `@cloudflare/workers-types`
61
+ * dependency; the real binding is assignable to it.
62
+ */
63
+ export interface AnalyticsEngineDataPoint {
64
+ /** Up to one sampling key, each ≤ 96 bytes. */
65
+ indexes?: (ArrayBuffer | string | null)[];
66
+ /** Up to 20 string/blob dimensions; total size ≤ 16 KB. */
67
+ blobs?: (ArrayBuffer | string | null)[];
68
+ /** Up to 20 numeric measurements. */
69
+ doubles?: number[];
70
+ }
71
+ /**
72
+ * The slice of a Cloudflare `AnalyticsEngineDataset` binding that
73
+ * {@link analyticsEngineMetrics} writes to. Declared structurally (like
74
+ * {@link ConsoleLike}) so a test can pass a spy and the composed Worker can pass
75
+ * the real `env.<DATASET>` binding.
76
+ */
77
+ export interface AnalyticsEngineDatasetLike {
78
+ writeDataPoint(point?: AnalyticsEngineDataPoint): void;
79
+ }
80
+ /** Tunables for {@link analyticsEngineMetrics}. */
81
+ export interface AnalyticsEngineMetricsOptions {
82
+ /**
83
+ * Fields merged into every data point (e.g. a deployment id or service name),
84
+ * mapped to blobs/doubles like any other field. Per-call fields win on a key
85
+ * collision.
86
+ */
87
+ readonly base?: LogFields;
88
+ }
89
+ /**
90
+ * A {@link Metrics} adapter that writes each call to a Cloudflare Workers
91
+ * Analytics Engine dataset via `writeDataPoint`.
92
+ *
93
+ * **Field mapping (deterministic, so positions are stable per event):**
94
+ * - `indexes[0]` = the `event` name (the sampling key; truncated to 96 bytes).
95
+ * - `blobs` = `[event, …string-valued fields]`, fields in **sorted key order**.
96
+ * - `doubles` = `[lead, …number/boolean fields]` in sorted key order, where
97
+ * `lead` is `1` for {@link Metrics.count} or the observed value for
98
+ * {@link Metrics.observe}, and booleans map to `1`/`0`.
99
+ *
100
+ * Non-scalar fields (objects, arrays) and `undefined`/`null`/non-finite numbers
101
+ * are dropped — metric fields must be scalar and safe to record. Because field
102
+ * positions follow sorted key order, an event SHOULD carry a stable field shape
103
+ * so `blobN` / `doubleN` mean the same thing across data points.
104
+ *
105
+ * Honors the {@link Metrics} non-throwing contract: a failing `writeDataPoint`
106
+ * is swallowed rather than thrown into the operation being measured.
107
+ */
108
+ export declare function analyticsEngineMetrics(dataset: AnalyticsEngineDatasetLike, options?: AnalyticsEngineMetricsOptions): Metrics;
109
+ //# sourceMappingURL=metrics.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metrics.d.ts","sourceRoot":"","sources":["../src/metrics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAEzC;;;;;;;;GAQG;AACH,MAAM,WAAW,OAAO;IACtB;;;OAGG;IACH,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,GAAG,IAAI,CAAC;IAC/C;;;OAGG;IACH,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,GAAG,IAAI,CAAC;CACjE;AAED;;;;GAIG;AACH,eAAO,MAAM,WAAW,EAAE,OAGzB,CAAC;AAEF;;;;GAIG;AACH,MAAM,WAAW,wBAAwB;IACvC,+CAA+C;IAC/C,OAAO,CAAC,EAAE,CAAC,WAAW,GAAG,MAAM,GAAG,IAAI,CAAC,EAAE,CAAC;IAC1C,2DAA2D;IAC3D,KAAK,CAAC,EAAE,CAAC,WAAW,GAAG,MAAM,GAAG,IAAI,CAAC,EAAE,CAAC;IACxC,qCAAqC;IACrC,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;CACpB;AAED;;;;;GAKG;AACH,MAAM,WAAW,0BAA0B;IACzC,cAAc,CAAC,KAAK,CAAC,EAAE,wBAAwB,GAAG,IAAI,CAAC;CACxD;AAED,mDAAmD;AACnD,MAAM,WAAW,6BAA6B;IAC5C;;;;OAIG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,CAAC;CAC3B;AAgED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,0BAA0B,EACnC,OAAO,GAAE,6BAAkC,GAC1C,OAAO,CAoCT"}
@@ -0,0 +1,152 @@
1
+ /**
2
+ * `@dwk/log` — a minimal, injectable **metrics** seam, companion to {@link Logger}.
3
+ *
4
+ * Where the {@link Logger} answers "what happened?" with one structured record
5
+ * per event, this seam answers "how often / how much?" — emitting queryable
6
+ * counters and observations so an operator can chart "SSRF blocks/min",
7
+ * "verification success rate", or "queue retries by reason" rather than scraping
8
+ * log lines. It mirrors the logging seam exactly: a package takes an **optional**
9
+ * {@link Metrics} in its config, defaulting to {@link noopMetrics}, and the
10
+ * composed Worker wires a concrete adapter **once** at the composition boundary.
11
+ * It does **no I/O of its own by default**.
12
+ *
13
+ * Like the rest of `@dwk/log` it is a **cross-standard reusable**: protocol-
14
+ * agnostic, holding no IndieWeb/Solid assumptions, with no Workers-runtime
15
+ * dependency. The {@link analyticsEngineMetrics} adapter targets Cloudflare
16
+ * Workers Analytics Engine through a **structural** binding type
17
+ * ({@link AnalyticsEngineDatasetLike}) — the same trick {@link ConsoleLike} uses
18
+ * for `console` — so this library never imports `@cloudflare/workers-types` and
19
+ * still unit-tests under plain Node.
20
+ *
21
+ * Metrics reuse the same **event taxonomy** and the same {@link LogFields} bags
22
+ * as logs, so logs and metrics share one vocabulary: pass the same
23
+ * `(event, fields)` to both seams. The **redaction policy is identical** — only
24
+ * hosts, ports, status, machine-readable reason/result codes, booleans, and
25
+ * counts; never tokens, bodies, or full URLs (use {@link hostFromUrl}).
26
+ *
27
+ * @see spec/observability.md
28
+ * @packageDocumentation
29
+ */
30
+ /**
31
+ * A metrics sink that discards everything. This is the default a package uses
32
+ * when its config supplies none, so metrics are strictly opt-in and pure libs
33
+ * stay side-effect-free in tests.
34
+ */
35
+ export const noopMetrics = {
36
+ count: () => { },
37
+ observe: () => { },
38
+ };
39
+ /** Analytics Engine limits (see the Workers Analytics Engine docs). */
40
+ const MAX_INDEX_BYTES = 96;
41
+ const MAX_BLOBS = 20;
42
+ const MAX_DOUBLES = 20;
43
+ const MAX_BLOB_BYTES_TOTAL = 16_384;
44
+ const utf8 = new TextEncoder();
45
+ function utf8ByteLength(value) {
46
+ return utf8.encode(value).length;
47
+ }
48
+ /** Truncate `value` to at most `maxBytes` UTF-8 bytes, on a char boundary. */
49
+ function truncateUtf8(value, maxBytes) {
50
+ const bytes = utf8.encode(value);
51
+ if (bytes.length <= maxBytes) {
52
+ return value;
53
+ }
54
+ // Find the boundary by inspecting the bytes rather than decoding a partial
55
+ // slice: that way we never emit U+FFFD for a split char (and never mistake a
56
+ // genuine trailing U+FFFD in the input for one). First back off any
57
+ // continuation bytes (0b10xxxxxx) sitting at the cut...
58
+ let len = maxBytes;
59
+ while (len > 0 && ((bytes[len - 1] ?? 0) & 0xc0) === 0x80) {
60
+ len--;
61
+ }
62
+ // ...then, if we're left just after a lead byte whose multibyte sequence the
63
+ // cut would split, drop that lead byte too; otherwise the whole sequence fit,
64
+ // so keep up to maxBytes.
65
+ const lead = bytes[len - 1] ?? 0;
66
+ if (len > 0 && lead >= 0xc0) {
67
+ const seqLen = lead >= 0xf0 ? 4 : lead >= 0xe0 ? 3 : 2;
68
+ len = len - 1 + seqLen > maxBytes ? len - 1 : maxBytes;
69
+ }
70
+ // subarray is a zero-copy view (unlike slice), and the bytes are now a whole
71
+ // number of UTF-8 sequences, so the decode is exact.
72
+ return new TextDecoder().decode(bytes.subarray(0, len));
73
+ }
74
+ /** Cap a blob list to AE's count and total-byte limits, truncating the last. */
75
+ function capBlobs(blobs) {
76
+ const out = [];
77
+ let total = 0;
78
+ for (const blob of blobs) {
79
+ if (out.length >= MAX_BLOBS) {
80
+ break;
81
+ }
82
+ const bytes = utf8ByteLength(blob);
83
+ if (total + bytes <= MAX_BLOB_BYTES_TOTAL) {
84
+ out.push(blob);
85
+ total += bytes;
86
+ continue;
87
+ }
88
+ const remaining = MAX_BLOB_BYTES_TOTAL - total;
89
+ if (remaining > 0) {
90
+ out.push(truncateUtf8(blob, remaining));
91
+ }
92
+ break;
93
+ }
94
+ return out;
95
+ }
96
+ /**
97
+ * A {@link Metrics} adapter that writes each call to a Cloudflare Workers
98
+ * Analytics Engine dataset via `writeDataPoint`.
99
+ *
100
+ * **Field mapping (deterministic, so positions are stable per event):**
101
+ * - `indexes[0]` = the `event` name (the sampling key; truncated to 96 bytes).
102
+ * - `blobs` = `[event, …string-valued fields]`, fields in **sorted key order**.
103
+ * - `doubles` = `[lead, …number/boolean fields]` in sorted key order, where
104
+ * `lead` is `1` for {@link Metrics.count} or the observed value for
105
+ * {@link Metrics.observe}, and booleans map to `1`/`0`.
106
+ *
107
+ * Non-scalar fields (objects, arrays) and `undefined`/`null`/non-finite numbers
108
+ * are dropped — metric fields must be scalar and safe to record. Because field
109
+ * positions follow sorted key order, an event SHOULD carry a stable field shape
110
+ * so `blobN` / `doubleN` mean the same thing across data points.
111
+ *
112
+ * Honors the {@link Metrics} non-throwing contract: a failing `writeDataPoint`
113
+ * is swallowed rather than thrown into the operation being measured.
114
+ */
115
+ export function analyticsEngineMetrics(dataset, options = {}) {
116
+ const base = options.base;
117
+ const write = (event, lead, fields) => {
118
+ const merged = { ...base, ...fields };
119
+ const blobs = [event];
120
+ const doubles = [lead];
121
+ for (const key of Object.keys(merged).sort()) {
122
+ const value = merged[key];
123
+ if (typeof value === "string") {
124
+ blobs.push(value);
125
+ }
126
+ else if (typeof value === "boolean") {
127
+ doubles.push(value ? 1 : 0);
128
+ }
129
+ else if (typeof value === "number" && Number.isFinite(value)) {
130
+ doubles.push(value);
131
+ }
132
+ // Anything else (undefined, null, objects, NaN, …) is intentionally
133
+ // dropped: metric fields must be scalar and safe to record.
134
+ }
135
+ try {
136
+ dataset.writeDataPoint({
137
+ indexes: [truncateUtf8(event, MAX_INDEX_BYTES)],
138
+ blobs: capBlobs(blobs),
139
+ doubles: doubles.slice(0, MAX_DOUBLES),
140
+ });
141
+ }
142
+ catch {
143
+ // Metrics MUST NOT throw: a failed write must never break the operation
144
+ // being measured. There is no fallback sink, so swallow it.
145
+ }
146
+ };
147
+ return {
148
+ count: (event, fields) => write(event, 1, fields),
149
+ observe: (event, value, fields) => write(event, Number.isFinite(value) ? value : 0, fields),
150
+ };
151
+ }
152
+ //# sourceMappingURL=metrics.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metrics.js","sourceRoot":"","sources":["../src/metrics.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@dwk/log",
3
+ "version": "0.1.0-beta.0",
4
+ "description": "Minimal injectable structured-logging and metrics seam. Cross-standard reusable; protocol-agnostic, no Workers runtime dependency.",
5
+ "keywords": [
6
+ "logging",
7
+ "structured-logging",
8
+ "observability",
9
+ "metrics",
10
+ "cloudflare-workers"
11
+ ],
12
+ "type": "module",
13
+ "license": "ISC",
14
+ "author": "David W. Keith <me@dwk.io>",
15
+ "homepage": "https://github.com/davidwkeith/workers/tree/main/packages/log#readme",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/davidwkeith/workers.git",
19
+ "directory": "packages/log"
20
+ },
21
+ "sideEffects": false,
22
+ "main": "./dist/index.js",
23
+ "types": "./dist/index.d.ts",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js"
28
+ }
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "src",
33
+ "!src/**/*.test.ts"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "scripts": {
39
+ "build": "tsc -p tsconfig.build.json",
40
+ "typecheck": "tsc -p tsconfig.json",
41
+ "clean": "rm -rf dist"
42
+ }
43
+ }
package/src/index.ts ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * `@dwk/log` — a minimal, injectable structured-logging seam.
3
+ *
4
+ * The `@dwk` packages handle untrusted, attacker-supplied input, so failures and
5
+ * security-relevant events (a blocked SSRF attempt, an auth rejection) must
6
+ * produce a signal rather than being silently swallowed. This library defines
7
+ * the seam that signal flows through. It deliberately does **no I/O of its own
8
+ * by default**: a package takes an optional {@link Logger} in its config and
9
+ * calls it; the composed Worker decides where the records actually go (Workers
10
+ * structured logs, Logpush, Analytics Engine, …) by wiring in a concrete logger.
11
+ *
12
+ * It is a **cross-standard reusable**: like `@dwk/dpop` and `@dwk/rdf`, it MUST
13
+ * stay free of IndieWeb/Solid assumptions so future `@dwk` standards adopt it
14
+ * unchanged. It holds no state, performs no protocol logic, and unit-tests
15
+ * without a Workers runtime.
16
+ *
17
+ * Logs are **structured events**, not free text: every call names a stable,
18
+ * dotted `event` (e.g. `webmention.ssrf.blocked`) plus a flat bag of fields, so
19
+ * an operator can query by event and field rather than grep prose. Event-name
20
+ * taxonomies are owned by each consuming package, not by this library.
21
+ *
22
+ * **Redaction is the caller's responsibility, but the seam helps:** never pass
23
+ * tokens, credentials, or full request/response bodies as fields. For URLs,
24
+ * prefer {@link hostFromUrl} so an attacker-supplied path/query never lands in a
25
+ * log line.
26
+ *
27
+ * Logs answer "what happened?"; the companion {@link Metrics} seam (same file
28
+ * family, same injection discipline) answers "how often / how much?" for the
29
+ * same events. See {@link ./metrics}.
30
+ *
31
+ * @see spec/observability.md
32
+ * @see spec/packages/log.md
33
+ * @packageDocumentation
34
+ */
35
+
36
+ export {
37
+ type Metrics,
38
+ noopMetrics,
39
+ analyticsEngineMetrics,
40
+ type AnalyticsEngineDatasetLike,
41
+ type AnalyticsEngineDataPoint,
42
+ type AnalyticsEngineMetricsOptions,
43
+ } from "./metrics";
44
+
45
+ /** Severity of a log record, in increasing order of importance. */
46
+ export type LogLevel = "debug" | "info" | "warn" | "error";
47
+
48
+ /**
49
+ * A flat bag of structured fields attached to a log record. Keys SHOULD be
50
+ * stable and queryable; values MUST be safe to record (no secrets, no bodies).
51
+ */
52
+ export type LogFields = Record<string, unknown>;
53
+
54
+ /**
55
+ * The logging seam every `@dwk` package writes to.
56
+ *
57
+ * Each method takes a stable, dotted `event` name and an optional bag of
58
+ * structured `fields`. Implementations MUST NOT throw — a logging failure must
59
+ * never break the operation being logged.
60
+ */
61
+ export interface Logger {
62
+ /** Verbose, developer-facing detail; off in production by default. */
63
+ debug(event: string, fields?: LogFields): void;
64
+ /** A normal, noteworthy outcome (e.g. a verification completed). */
65
+ info(event: string, fields?: LogFields): void;
66
+ /** A handled-but-notable event (e.g. a blocked SSRF attempt, a retry). */
67
+ warn(event: string, fields?: LogFields): void;
68
+ /** A failure that needs attention. */
69
+ error(event: string, fields?: LogFields): void;
70
+ }
71
+
72
+ /**
73
+ * A logger that discards everything. This is the default a package uses when
74
+ * its config supplies none, so logging is strictly opt-in and pure libs stay
75
+ * silent and side-effect-free in tests.
76
+ */
77
+ export const noopLogger: Logger = {
78
+ debug: () => {},
79
+ info: () => {},
80
+ warn: () => {},
81
+ error: () => {},
82
+ };
83
+
84
+ /** Numeric ordering of {@link LogLevel} used for threshold filtering. */
85
+ const LEVEL_ORDER: Record<LogLevel, number> = {
86
+ debug: 10,
87
+ info: 20,
88
+ warn: 30,
89
+ error: 40,
90
+ };
91
+
92
+ /**
93
+ * The slice of the global `console` (or any compatible sink) that
94
+ * {@link consoleLogger} writes to. Declared structurally so a test can pass a
95
+ * spy and the composed Worker can pass `console`.
96
+ */
97
+ export interface ConsoleLike {
98
+ debug(...args: unknown[]): void;
99
+ info(...args: unknown[]): void;
100
+ warn(...args: unknown[]): void;
101
+ error(...args: unknown[]): void;
102
+ }
103
+
104
+ /** Tunables for {@link consoleLogger}. */
105
+ export interface ConsoleLoggerOptions {
106
+ /** Sink to write to; defaults to the global `console`. */
107
+ readonly console?: ConsoleLike;
108
+ /** Drop records below this level (default `"debug"`, i.e. emit everything). */
109
+ readonly minLevel?: LogLevel;
110
+ /** Fields merged into every record (e.g. a deployment or request id). */
111
+ readonly base?: LogFields;
112
+ /** Timestamp source, for deterministic tests (default `Date.now` as ISO). */
113
+ readonly now?: () => string;
114
+ }
115
+
116
+ /**
117
+ * A {@link Logger} that emits one JSON object per record to `console`, suitable
118
+ * for Cloudflare Workers structured logs / Logpush.
119
+ *
120
+ * Each record is `{ level, event, time, ...base, ...fields }`, serialized with
121
+ * `JSON.stringify` (which drops `undefined`-valued fields, so optional context
122
+ * can be passed unconditionally). Records below `minLevel` are dropped. The
123
+ * matching `console` method is used per level so a platform's level routing
124
+ * still applies.
125
+ *
126
+ * Honors the {@link Logger} non-throwing contract: if a field is not
127
+ * JSON-serializable (a circular reference, a `BigInt`, …) the record is replaced
128
+ * by a minimal `log.serialization_failed` record rather than throwing into the
129
+ * operation being logged.
130
+ */
131
+ export function consoleLogger(options: ConsoleLoggerOptions = {}): Logger {
132
+ const sink = options.console ?? console;
133
+ const threshold = LEVEL_ORDER[options.minLevel ?? "debug"];
134
+ const now = options.now ?? (() => new Date().toISOString());
135
+ const base = options.base;
136
+
137
+ const emit =
138
+ (level: LogLevel) =>
139
+ (event: string, fields?: LogFields): void => {
140
+ if (LEVEL_ORDER[level] < threshold) {
141
+ return;
142
+ }
143
+ const record: LogFields = {
144
+ level,
145
+ event,
146
+ time: now(),
147
+ ...base,
148
+ ...fields,
149
+ };
150
+ try {
151
+ sink[level](JSON.stringify(record));
152
+ } catch (err) {
153
+ // Honor the Logger "MUST NOT throw" contract: a non-serializable field
154
+ // (circular reference, BigInt, …) must never break the operation being
155
+ // logged. Fall back to a minimal, guaranteed-serializable record. The
156
+ // serializer's own error message is safe to log — it is not caller data.
157
+ sink.error(
158
+ JSON.stringify({
159
+ level: "error",
160
+ event: "log.serialization_failed",
161
+ time: record.time,
162
+ failedEvent: event,
163
+ error: err instanceof Error ? err.message : String(err),
164
+ }),
165
+ );
166
+ }
167
+ };
168
+
169
+ return {
170
+ debug: emit("debug"),
171
+ info: emit("info"),
172
+ warn: emit("warn"),
173
+ error: emit("error"),
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Wrap `logger` so that `context` is merged into the fields of every record.
179
+ *
180
+ * Use this to bind request- or pod-scoped context (a request id, a pod id) once
181
+ * at the composition boundary instead of repeating it at every call site. The
182
+ * per-call `fields` win on key collisions.
183
+ */
184
+ export function withContext(logger: Logger, context: LogFields): Logger {
185
+ const merge = (fields?: LogFields): LogFields => ({ ...context, ...fields });
186
+ return {
187
+ debug: (event, fields) => logger.debug(event, merge(fields)),
188
+ info: (event, fields) => logger.info(event, merge(fields)),
189
+ warn: (event, fields) => logger.warn(event, merge(fields)),
190
+ error: (event, fields) => logger.error(event, merge(fields)),
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Extract just the host from a URL string, for safe logging.
196
+ *
197
+ * Returns the `host` (name plus any non-default port) and nothing else — no
198
+ * scheme, path, query, or userinfo — so an attacker-supplied `source`/`target`
199
+ * URL never lands in a log line in full. Returns `undefined` when `raw` is not a
200
+ * parseable URL.
201
+ */
202
+ export function hostFromUrl(raw: string): string | undefined {
203
+ try {
204
+ return new URL(raw).host;
205
+ } catch {
206
+ return undefined;
207
+ }
208
+ }
package/src/metrics.ts ADDED
@@ -0,0 +1,219 @@
1
+ /**
2
+ * `@dwk/log` — a minimal, injectable **metrics** seam, companion to {@link Logger}.
3
+ *
4
+ * Where the {@link Logger} answers "what happened?" with one structured record
5
+ * per event, this seam answers "how often / how much?" — emitting queryable
6
+ * counters and observations so an operator can chart "SSRF blocks/min",
7
+ * "verification success rate", or "queue retries by reason" rather than scraping
8
+ * log lines. It mirrors the logging seam exactly: a package takes an **optional**
9
+ * {@link Metrics} in its config, defaulting to {@link noopMetrics}, and the
10
+ * composed Worker wires a concrete adapter **once** at the composition boundary.
11
+ * It does **no I/O of its own by default**.
12
+ *
13
+ * Like the rest of `@dwk/log` it is a **cross-standard reusable**: protocol-
14
+ * agnostic, holding no IndieWeb/Solid assumptions, with no Workers-runtime
15
+ * dependency. The {@link analyticsEngineMetrics} adapter targets Cloudflare
16
+ * Workers Analytics Engine through a **structural** binding type
17
+ * ({@link AnalyticsEngineDatasetLike}) — the same trick {@link ConsoleLike} uses
18
+ * for `console` — so this library never imports `@cloudflare/workers-types` and
19
+ * still unit-tests under plain Node.
20
+ *
21
+ * Metrics reuse the same **event taxonomy** and the same {@link LogFields} bags
22
+ * as logs, so logs and metrics share one vocabulary: pass the same
23
+ * `(event, fields)` to both seams. The **redaction policy is identical** — only
24
+ * hosts, ports, status, machine-readable reason/result codes, booleans, and
25
+ * counts; never tokens, bodies, or full URLs (use {@link hostFromUrl}).
26
+ *
27
+ * @see spec/observability.md
28
+ * @packageDocumentation
29
+ */
30
+
31
+ import type { LogFields } from "./index";
32
+
33
+ /**
34
+ * The metrics seam every `@dwk` package writes to, alongside {@link Logger}.
35
+ *
36
+ * Both methods name the same stable, dotted `event` as the logger and an
37
+ * optional bag of structured {@link LogFields} — the adapter turns string fields
38
+ * into queryable dimensions and numeric/boolean fields into measurements.
39
+ * Implementations MUST NOT throw — a metrics failure must never break the
40
+ * operation being measured.
41
+ */
42
+ export interface Metrics {
43
+ /**
44
+ * Record one occurrence of `event` (a counter increment of 1). Use for
45
+ * tallies like "SSRF blocks", "mentions received", "retries".
46
+ */
47
+ count(event: string, fields?: LogFields): void;
48
+ /**
49
+ * Record a numeric observation of `value` for `event` (a gauge/histogram
50
+ * sample). Use for measurements like a fetch latency in milliseconds.
51
+ */
52
+ observe(event: string, value: number, fields?: LogFields): void;
53
+ }
54
+
55
+ /**
56
+ * A metrics sink that discards everything. This is the default a package uses
57
+ * when its config supplies none, so metrics are strictly opt-in and pure libs
58
+ * stay side-effect-free in tests.
59
+ */
60
+ export const noopMetrics: Metrics = {
61
+ count: () => {},
62
+ observe: () => {},
63
+ };
64
+
65
+ /**
66
+ * One Analytics Engine data point. A structural mirror of Cloudflare's
67
+ * `AnalyticsEngineDataPoint` so this library needs no `@cloudflare/workers-types`
68
+ * dependency; the real binding is assignable to it.
69
+ */
70
+ export interface AnalyticsEngineDataPoint {
71
+ /** Up to one sampling key, each ≤ 96 bytes. */
72
+ indexes?: (ArrayBuffer | string | null)[];
73
+ /** Up to 20 string/blob dimensions; total size ≤ 16 KB. */
74
+ blobs?: (ArrayBuffer | string | null)[];
75
+ /** Up to 20 numeric measurements. */
76
+ doubles?: number[];
77
+ }
78
+
79
+ /**
80
+ * The slice of a Cloudflare `AnalyticsEngineDataset` binding that
81
+ * {@link analyticsEngineMetrics} writes to. Declared structurally (like
82
+ * {@link ConsoleLike}) so a test can pass a spy and the composed Worker can pass
83
+ * the real `env.<DATASET>` binding.
84
+ */
85
+ export interface AnalyticsEngineDatasetLike {
86
+ writeDataPoint(point?: AnalyticsEngineDataPoint): void;
87
+ }
88
+
89
+ /** Tunables for {@link analyticsEngineMetrics}. */
90
+ export interface AnalyticsEngineMetricsOptions {
91
+ /**
92
+ * Fields merged into every data point (e.g. a deployment id or service name),
93
+ * mapped to blobs/doubles like any other field. Per-call fields win on a key
94
+ * collision.
95
+ */
96
+ readonly base?: LogFields;
97
+ }
98
+
99
+ /** Analytics Engine limits (see the Workers Analytics Engine docs). */
100
+ const MAX_INDEX_BYTES = 96;
101
+ const MAX_BLOBS = 20;
102
+ const MAX_DOUBLES = 20;
103
+ const MAX_BLOB_BYTES_TOTAL = 16_384;
104
+
105
+ const utf8 = new TextEncoder();
106
+
107
+ function utf8ByteLength(value: string): number {
108
+ return utf8.encode(value).length;
109
+ }
110
+
111
+ /** Truncate `value` to at most `maxBytes` UTF-8 bytes, on a char boundary. */
112
+ function truncateUtf8(value: string, maxBytes: number): string {
113
+ const bytes = utf8.encode(value);
114
+ if (bytes.length <= maxBytes) {
115
+ return value;
116
+ }
117
+ // Find the boundary by inspecting the bytes rather than decoding a partial
118
+ // slice: that way we never emit U+FFFD for a split char (and never mistake a
119
+ // genuine trailing U+FFFD in the input for one). First back off any
120
+ // continuation bytes (0b10xxxxxx) sitting at the cut...
121
+ let len = maxBytes;
122
+ while (len > 0 && ((bytes[len - 1] ?? 0) & 0xc0) === 0x80) {
123
+ len--;
124
+ }
125
+ // ...then, if we're left just after a lead byte whose multibyte sequence the
126
+ // cut would split, drop that lead byte too; otherwise the whole sequence fit,
127
+ // so keep up to maxBytes.
128
+ const lead = bytes[len - 1] ?? 0;
129
+ if (len > 0 && lead >= 0xc0) {
130
+ const seqLen = lead >= 0xf0 ? 4 : lead >= 0xe0 ? 3 : 2;
131
+ len = len - 1 + seqLen > maxBytes ? len - 1 : maxBytes;
132
+ }
133
+ // subarray is a zero-copy view (unlike slice), and the bytes are now a whole
134
+ // number of UTF-8 sequences, so the decode is exact.
135
+ return new TextDecoder().decode(bytes.subarray(0, len));
136
+ }
137
+
138
+ /** Cap a blob list to AE's count and total-byte limits, truncating the last. */
139
+ function capBlobs(blobs: string[]): string[] {
140
+ const out: string[] = [];
141
+ let total = 0;
142
+ for (const blob of blobs) {
143
+ if (out.length >= MAX_BLOBS) {
144
+ break;
145
+ }
146
+ const bytes = utf8ByteLength(blob);
147
+ if (total + bytes <= MAX_BLOB_BYTES_TOTAL) {
148
+ out.push(blob);
149
+ total += bytes;
150
+ continue;
151
+ }
152
+ const remaining = MAX_BLOB_BYTES_TOTAL - total;
153
+ if (remaining > 0) {
154
+ out.push(truncateUtf8(blob, remaining));
155
+ }
156
+ break;
157
+ }
158
+ return out;
159
+ }
160
+
161
+ /**
162
+ * A {@link Metrics} adapter that writes each call to a Cloudflare Workers
163
+ * Analytics Engine dataset via `writeDataPoint`.
164
+ *
165
+ * **Field mapping (deterministic, so positions are stable per event):**
166
+ * - `indexes[0]` = the `event` name (the sampling key; truncated to 96 bytes).
167
+ * - `blobs` = `[event, …string-valued fields]`, fields in **sorted key order**.
168
+ * - `doubles` = `[lead, …number/boolean fields]` in sorted key order, where
169
+ * `lead` is `1` for {@link Metrics.count} or the observed value for
170
+ * {@link Metrics.observe}, and booleans map to `1`/`0`.
171
+ *
172
+ * Non-scalar fields (objects, arrays) and `undefined`/`null`/non-finite numbers
173
+ * are dropped — metric fields must be scalar and safe to record. Because field
174
+ * positions follow sorted key order, an event SHOULD carry a stable field shape
175
+ * so `blobN` / `doubleN` mean the same thing across data points.
176
+ *
177
+ * Honors the {@link Metrics} non-throwing contract: a failing `writeDataPoint`
178
+ * is swallowed rather than thrown into the operation being measured.
179
+ */
180
+ export function analyticsEngineMetrics(
181
+ dataset: AnalyticsEngineDatasetLike,
182
+ options: AnalyticsEngineMetricsOptions = {},
183
+ ): Metrics {
184
+ const base = options.base;
185
+
186
+ const write = (event: string, lead: number, fields?: LogFields): void => {
187
+ const merged: LogFields = { ...base, ...fields };
188
+ const blobs: string[] = [event];
189
+ const doubles: number[] = [lead];
190
+ for (const key of Object.keys(merged).sort()) {
191
+ const value = merged[key];
192
+ if (typeof value === "string") {
193
+ blobs.push(value);
194
+ } else if (typeof value === "boolean") {
195
+ doubles.push(value ? 1 : 0);
196
+ } else if (typeof value === "number" && Number.isFinite(value)) {
197
+ doubles.push(value);
198
+ }
199
+ // Anything else (undefined, null, objects, NaN, …) is intentionally
200
+ // dropped: metric fields must be scalar and safe to record.
201
+ }
202
+ try {
203
+ dataset.writeDataPoint({
204
+ indexes: [truncateUtf8(event, MAX_INDEX_BYTES)],
205
+ blobs: capBlobs(blobs),
206
+ doubles: doubles.slice(0, MAX_DOUBLES),
207
+ });
208
+ } catch {
209
+ // Metrics MUST NOT throw: a failed write must never break the operation
210
+ // being measured. There is no fallback sink, so swallow it.
211
+ }
212
+ };
213
+
214
+ return {
215
+ count: (event, fields) => write(event, 1, fields),
216
+ observe: (event, value, fields) =>
217
+ write(event, Number.isFinite(value) ? value : 0, fields),
218
+ };
219
+ }