@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 +15 -0
- package/README.md +122 -0
- package/dist/index.d.ts +121 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +141 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics.d.ts +109 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +152 -0
- package/dist/metrics.js.map +1 -0
- package/package.json +43 -0
- package/src/index.ts +208 -0
- package/src/metrics.ts +219 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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"}
|
package/dist/metrics.js
ADDED
|
@@ -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
|
+
}
|