@ultimat3/core 1.0.0 → 1.2.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/README.md +88 -2
- package/package.json +1 -1
- package/src/env-example.ts +123 -0
- package/src/env.ts +18 -1
- package/src/environment.ts +74 -0
- package/src/error-codes.ts +4 -0
- package/src/index.ts +79 -1
- package/src/logger.ts +8 -2
- package/src/metrics-text.ts +81 -0
- package/src/metrics.ts +0 -0
- package/src/runtime-metrics.ts +86 -0
- package/src/secret.ts +67 -0
- package/src/telemetry.ts +8 -0
package/README.md
CHANGED
|
@@ -11,12 +11,18 @@ Zero dependencies, zero `@ultimat3/*` imports.
|
|
|
11
11
|
| request context on `AsyncLocalStorage` | `context.ts` |
|
|
12
12
|
| `Actor` (`user \| service \| agent \| anonymous`) | `actor.ts` |
|
|
13
13
|
| typed env validated at boot | `env.ts` |
|
|
14
|
+
| `.env.example` rendered from that schema, and its drift check | `env-example.ts` |
|
|
15
|
+
| named environments + `ULTIMATE_ENV` resolution | `environment.ts` |
|
|
16
|
+
| a value that cannot be printed by accident | `secret.ts` |
|
|
14
17
|
| `defineConfig()` for `app.config.ts` | `config.ts` |
|
|
15
18
|
| runtime roles + `ROLE` resolution | `roles.ts` |
|
|
16
19
|
| `Clock` — the only source of "now" | `clock.ts` |
|
|
17
20
|
| UUIDv7, nanoid, branded ids | `ids.ts` |
|
|
18
21
|
| structured JSON logging + redaction | `logger.ts` |
|
|
19
22
|
| OTel-shaped spans, always on, no-op by default | `telemetry.ts` |
|
|
23
|
+
| OTel-shaped counter / gauge / histogram, same seam | `metrics.ts` |
|
|
24
|
+
| the `/metrics` scrape body | `metrics-text.ts` |
|
|
25
|
+
| the series every process emits, incl. what the chart scales on | `runtime-metrics.ts` |
|
|
20
26
|
| graceful drain, `/healthz`, `/readyz` | `lifecycle.ts` |
|
|
21
27
|
| the sockets this process opened, so a self-request is not egress | `listeners.ts` |
|
|
22
28
|
| `defineService('orgs', …)` → `ctx.orgs`, rebuilt per actor | `service.ts` |
|
|
@@ -90,7 +96,7 @@ registered survives an actor swap unrebuilt.
|
|
|
90
96
|
export const env = defineEnv({
|
|
91
97
|
DATABASE_URL: { type: 'url', secret: true },
|
|
92
98
|
PORT: { type: 'port', default: 3000 },
|
|
93
|
-
|
|
99
|
+
REGION: { type: 'enum', values: ['us', 'eu'] },
|
|
94
100
|
SENTRY_DSN: { type: 'url', required: false },
|
|
95
101
|
NATS_URL: { type: 'url', role: 'sync' }, // only required for ROLE=sync
|
|
96
102
|
});
|
|
@@ -99,7 +105,46 @@ export const env = defineEnv({
|
|
|
99
105
|
Every missing or malformed key is listed in one `X_ENV_MISSING`. `secret: true` keys are
|
|
100
106
|
redacted in logs and masked in `checkEnv()` output; `describeEnv()` emits declarations only,
|
|
101
107
|
safe for `x.manifest.json`. Omit `required` for required — `required: false` is the only
|
|
102
|
-
loosening.
|
|
108
|
+
loosening. Never declare an env var for *which deploy this is* — that is `ULTIMATE_ENV`, below.
|
|
109
|
+
|
|
110
|
+
`.env.example` is a **projection** of that schema, never a second list:
|
|
111
|
+
`renderEnvExample(schema)` writes it, `assertEnvExample(schema, text)` fails with
|
|
112
|
+
`X_ENV_EXAMPLE_DRIFT` when a declared key has no line — the failure that otherwise arrives as
|
|
113
|
+
somebody else's `X_ENV_MISSING` on a variable nobody documented.
|
|
114
|
+
|
|
115
|
+
Loading `.env` is **Bun's**, not ours. `envFileCandidates()` states what it does, measured:
|
|
116
|
+
`.env` → `.env.<mode>` → `.env.local`, with `.env.local` skipped under test, and the mode being
|
|
117
|
+
`production`, `test` or **`development` for everything else — `staging` included**. There is no
|
|
118
|
+
`.env.staging`; a staging deploy carries real environment variables.
|
|
119
|
+
|
|
120
|
+
## One environment, one key
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
resolveEnvironment(); // 'development' | 'test' | 'staging' | 'production'
|
|
124
|
+
isProduction(); // exact; nothing else counts
|
|
125
|
+
isLocal(); // development or test — never staging
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`ULTIMATE_ENV` is the key, `NODE_ENV` the fallback (platforms already set it). Values are
|
|
129
|
+
`NODE_ENV`'s spellings plus `staging` — `prod` and `dev` are typos, not aliases, and
|
|
130
|
+
`ULTIMATE_ENV=prod` is `X_ENVIRONMENT_INVALID`. An unrecognised `NODE_ENV` is *not* an error: it
|
|
131
|
+
is not our key. This is the twin of `roles.ts` — `ROLE` says what the process does,
|
|
132
|
+
`ULTIMATE_ENV` says which deploy it belongs to.
|
|
133
|
+
|
|
134
|
+
## A secret is redacted by value, not by name
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
const dsn = secret(process.env.DATABASE_URL ?? '', 'DATABASE_URL');
|
|
138
|
+
logger.info('boot', { dsn }); // {"dsn":"[redacted]"}
|
|
139
|
+
connect(revealSecret(dsn)); // the one greppable way out
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`redactKeys()` catches a secret travelling under a name someone remembered to list. A `Secret`
|
|
143
|
+
box catches the other case: `String()`, template literals, `+`, `JSON.stringify`, `console.log`,
|
|
144
|
+
the logger and an error's `meta` all render `[redacted]`, whatever key it sits under. It is
|
|
145
|
+
frozen and everything but `label` is non-enumerable, so `{ ...dsn }` cannot spread the value back
|
|
146
|
+
out. There is no vault integration and there will not be one — that is a platform primitive
|
|
147
|
+
(axiom 7); a `Secret` plus the platform's own secret store is the whole design.
|
|
103
148
|
|
|
104
149
|
## Time, ids, telemetry, drain
|
|
105
150
|
|
|
@@ -109,12 +154,53 @@ loosening.
|
|
|
109
154
|
- `withSpan('action.publishPost', fn)` is free until `configureTelemetry({ exporter })`.
|
|
110
155
|
Traces cross process boundaries via `traceparent()` / `parseTraceparent()` — Sentry, Honeycomb
|
|
111
156
|
and OTLP all plug in as a `SpanExporter`.
|
|
157
|
+
- Metrics are the same shape one signal over: `counter()`, `gauge()`, `histogram()`, aggregated
|
|
158
|
+
in process, free until `configureMetrics({ exporter })`. See below.
|
|
112
159
|
- `onShutdown(name, hook, { phase })` with phases `accept → inflight → close` under one
|
|
113
160
|
deadline; `readyzPayload()` flips to 503 the moment draining starts, `healthzPayload()` stays
|
|
114
161
|
200 until stopped.
|
|
115
162
|
- Anything that opens a socket calls `markListening(server.url.origin)` and releases it on close.
|
|
116
163
|
That is what tells the sealed test network a loopback request is this process, not egress.
|
|
117
164
|
|
|
165
|
+
## Metrics: same seam as tracing, one signal over
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
const published = counter('posts_published_total', { description: 'posts published' });
|
|
169
|
+
published.add(1, { plan: 'pro' });
|
|
170
|
+
|
|
171
|
+
gauge('queue_depth', { observe: () => pending() }); // read at scrape time, never stale
|
|
172
|
+
histogram('render_duration_seconds').record(ms / 1000);
|
|
173
|
+
|
|
174
|
+
metricsText(); // the /metrics body, at METRICS_PATH, METRICS_CONTENT_TYPE
|
|
175
|
+
collectMetrics(); // the same numbers as data, for a MetricExporter
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
| | |
|
|
179
|
+
|---|---|
|
|
180
|
+
| Kinds | `counter` (monotonic sum), `gauge` (`record` / `add`, or an async `observe`), `histogram` (explicit bounds, OTel's default latency set) |
|
|
181
|
+
| Temporality | cumulative, as OTel defines it — a read never resets a counter, so two scrapers cannot steal each other's samples |
|
|
182
|
+
| Names | lowercase `snake_case`, the intersection every exposition format accepts. Dotted OTel names survive OTLP and die at a Prometheus scrape |
|
|
183
|
+
| Attributes | `string \| number \| boolean` only — each distinct set is a stored series, so a user id here is an outage |
|
|
184
|
+
| Driver seam | `MetricExporter`, defaulting to a no-op. `memoryMetricExporter()` for tests, `startMetricExport(ms)` for a periodic push |
|
|
185
|
+
| Not shipped | an OTLP client. It is bytes on a wire and a dependency; the seam is here, the driver is yours — or scrape `/metrics` with an agent that already speaks it |
|
|
186
|
+
|
|
187
|
+
`runtime-metrics.ts` holds the series every process emits, and `SCALING_METRICS` maps each
|
|
188
|
+
`ScalingSignal` from `roles.ts` to the one that carries it — so the role table, the chart and the
|
|
189
|
+
process cannot drift apart:
|
|
190
|
+
|
|
191
|
+
| Role scales on | Series | Instrument |
|
|
192
|
+
|---|---|---|
|
|
193
|
+
| `rps` | `http_requests_total` | counter; `rps` is a **rate** the adapter derives (`rate(http_requests_total[1m])`), never a stored number |
|
|
194
|
+
| `ws-connections` | `connections` | gauge, `+1`/`-1` |
|
|
195
|
+
| `queue-depth` | `queue_depth` | gauge, by `queue` label |
|
|
196
|
+
|
|
197
|
+
`As of 2026-08` all three are emitted and scraped. One call site per package — `recordRequest`
|
|
198
|
+
from `@ultimat3/http`'s pipeline, `recordConnection` from `@ultimat3/realtime`'s socket table,
|
|
199
|
+
`recordQueueDepth` from `@ultimat3/jobs`' worker loop — and `@ultimat3/cli` serves `metricsText()`
|
|
200
|
+
at `METRICS_PATH` on `METRICS_PORT` (9090), for every role rather than only the ones that open an
|
|
201
|
+
HTTP socket. Labels are route **patterns**, status **classes** and queue names: nothing
|
|
202
|
+
per-user, per-id or attacker-chosen ever becomes a series.
|
|
203
|
+
|
|
118
204
|
## One cursor, everywhere
|
|
119
205
|
|
|
120
206
|
```ts
|
package/package.json
CHANGED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Single responsibility: `.env.example` is a PROJECTION of the `defineEnv()` schema, never a
|
|
2
|
+
// second hand-maintained list. Render it from the declarations, and report drift when the file on
|
|
3
|
+
// disk has fallen behind. Loading `.env` itself is Bun's job — see `envFileCandidates()`.
|
|
4
|
+
|
|
5
|
+
import type { EnvSchema, EnvVarDecl } from './env';
|
|
6
|
+
import { type CodedErrorInit, UltimateError } from './errors';
|
|
7
|
+
|
|
8
|
+
export class EnvExampleDriftError extends UltimateError {
|
|
9
|
+
static readonly code = 'X_ENV_EXAMPLE_DRIFT';
|
|
10
|
+
override readonly name = 'EnvExampleDriftError';
|
|
11
|
+
constructor(init: CodedErrorInit) {
|
|
12
|
+
super({ ...init, code: EnvExampleDriftError.code });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
17
|
+
|
|
18
|
+
export const ENV_EXAMPLE_PATH = '.env.example';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What Bun loads by itself, lowest precedence first — measured against Bun 1.3, not assumed.
|
|
22
|
+
*
|
|
23
|
+
* The mode is NOT `ULTIMATE_ENV` and not even `NODE_ENV` verbatim: Bun reads `.env.production`
|
|
24
|
+
* for `NODE_ENV=production`, `.env.test` for `test`, and `.env.development` for **everything
|
|
25
|
+
* else, `staging` included**. So a `.env.staging` file is never read, which is why a named
|
|
26
|
+
* environment is carried by real environment variables (`ULTIMATE_ENV` plus the platform's own
|
|
27
|
+
* config) and never by a per-environment dotenv file.
|
|
28
|
+
*/
|
|
29
|
+
export function envFileCandidates(nodeEnv?: string | undefined): readonly string[] {
|
|
30
|
+
const mode = nodeEnv === 'production' || nodeEnv === 'test' ? nodeEnv : 'development';
|
|
31
|
+
const files = ['.env', `.env.${mode}`];
|
|
32
|
+
// Bun deliberately skips `.env.local` under test so a personal override cannot change a suite.
|
|
33
|
+
return mode === 'test' ? files : [...files, '.env.local'];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function typeLabel(decl: EnvVarDecl): string {
|
|
37
|
+
return decl.type === 'enum' ? decl.values.join(' | ') : decl.type;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function annotation(decl: EnvVarDecl): string {
|
|
41
|
+
const parts = [decl.required === false ? 'optional' : 'required', typeLabel(decl)];
|
|
42
|
+
if (decl.secret === true) parts.push('secret');
|
|
43
|
+
if (decl.role !== undefined) {
|
|
44
|
+
parts.push(`role ${(typeof decl.role === 'string' ? [decl.role] : decl.role).join('/')}`);
|
|
45
|
+
}
|
|
46
|
+
return parts.join(' · ');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A secret never carries a value here even when the declaration has a default: this file is
|
|
51
|
+
* committed, and a placeholder that happens to work is a credential nobody rotates.
|
|
52
|
+
*/
|
|
53
|
+
function exampleValue(decl: EnvVarDecl): string {
|
|
54
|
+
if (decl.secret === true) return '';
|
|
55
|
+
if (decl.default !== undefined) return String(decl.default);
|
|
56
|
+
return decl.type === 'enum' ? (decl.values[0] ?? '') : '';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface EnvExampleOptions {
|
|
60
|
+
/** Extra keys the app sets outside the schema (`ROLE`, `ULTIMATE_ENV`), rendered commented. */
|
|
61
|
+
readonly extras?: readonly string[] | undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Deterministic: declaration order in, declaration order out, so a rewrite diffs to nothing. */
|
|
65
|
+
export function renderEnvExample(schema: EnvSchema, options?: EnvExampleOptions): string {
|
|
66
|
+
const lines = [
|
|
67
|
+
'# Generated from defineEnv() — regenerate with renderEnvExample() from @ultimat3/core.',
|
|
68
|
+
'# Commit this file. Never commit .env: Bun loads .env, .env.<mode> and .env.local for you.',
|
|
69
|
+
];
|
|
70
|
+
for (const [key, decl] of Object.entries(schema)) {
|
|
71
|
+
lines.push('');
|
|
72
|
+
if (decl.description !== undefined) lines.push(`# ${decl.description}`);
|
|
73
|
+
lines.push(`# ${annotation(decl)}`);
|
|
74
|
+
lines.push(`${key}=${exampleValue(decl)}`);
|
|
75
|
+
}
|
|
76
|
+
for (const extra of options?.extras ?? []) lines.push('', `# ${extra}=`);
|
|
77
|
+
return `${lines.join('\n')}\n`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Keys only — values in a dotenv file are Bun's to parse, and half of them are placeholders. */
|
|
81
|
+
export function parseEnvKeys(text: string): readonly string[] {
|
|
82
|
+
const keys: string[] = [];
|
|
83
|
+
for (const raw of text.split('\n')) {
|
|
84
|
+
const line = raw.trim().replace(/^export\s+/, '');
|
|
85
|
+
if (line === '' || line.startsWith('#')) continue;
|
|
86
|
+
const separator = line.indexOf('=');
|
|
87
|
+
if (separator <= 0) continue;
|
|
88
|
+
const key = line.slice(0, separator).trim();
|
|
89
|
+
if (ENV_KEY_RE.test(key)) keys.push(key);
|
|
90
|
+
}
|
|
91
|
+
return keys;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface EnvExampleReport {
|
|
95
|
+
readonly ok: boolean;
|
|
96
|
+
/** Declared in the schema, absent from the file. Always a defect. */
|
|
97
|
+
readonly missing: readonly string[];
|
|
98
|
+
/** In the file, not in the schema. Reported, never fatal — apps set keys nothing declares. */
|
|
99
|
+
readonly extra: readonly string[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function checkEnvExample(schema: EnvSchema, text: string): EnvExampleReport {
|
|
103
|
+
const declared = Object.keys(schema);
|
|
104
|
+
const present = new Set(parseEnvKeys(text));
|
|
105
|
+
const missing = declared.filter((key) => !present.has(key));
|
|
106
|
+
const extra = [...present].filter((key) => !declared.includes(key));
|
|
107
|
+
return { ok: missing.length === 0, missing, extra };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Throws `X_ENV_EXAMPLE_DRIFT` when the committed example has fallen behind the schema — the
|
|
112
|
+
* failure an agent hits *before* a teammate hits `X_ENV_MISSING` on a variable nobody told them
|
|
113
|
+
* about.
|
|
114
|
+
*/
|
|
115
|
+
export function assertEnvExample(schema: EnvSchema, text: string, path = ENV_EXAMPLE_PATH): void {
|
|
116
|
+
const report = checkEnvExample(schema, text);
|
|
117
|
+
if (report.ok) return;
|
|
118
|
+
throw new EnvExampleDriftError({
|
|
119
|
+
cause: `${path} does not declare ${report.missing.join(', ')}, declared by defineEnv()`,
|
|
120
|
+
fix: `Bun.write('${path}', renderEnvExample(schema)) — regenerate it from the declarations`,
|
|
121
|
+
meta: { path, missing: report.missing, extra: report.extra },
|
|
122
|
+
});
|
|
123
|
+
}
|
package/src/env.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// discover six missing variables.
|
|
4
4
|
|
|
5
5
|
import { EnvMissingError } from './errors';
|
|
6
|
-
import { redactKeys } from './logger';
|
|
6
|
+
import { REDACTED, redactKeys } from './logger';
|
|
7
7
|
import { type Role, resolveRole } from './roles';
|
|
8
8
|
|
|
9
9
|
export type EnvVarType = 'string' | 'url' | 'number' | 'integer' | 'port' | 'boolean' | 'enum';
|
|
@@ -231,6 +231,23 @@ export function defineEnv<const S extends EnvSchema>(schema: S, options?: EnvOpt
|
|
|
231
231
|
return Object.freeze(report.values) as Env<S>;
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
/**
|
|
235
|
+
* The resolved values with every `secret: true` key replaced. `checkEnv().values` carries the REAL
|
|
236
|
+
* values because `defineEnv()` has to return them — so anything that PRINTS a report (`x env check
|
|
237
|
+
* --json`, a doctor line, a log field) renders this instead, and the masking lives in one place
|
|
238
|
+
* rather than at each printer.
|
|
239
|
+
*/
|
|
240
|
+
export function maskedEnvValues(
|
|
241
|
+
schema: EnvSchema,
|
|
242
|
+
values: Readonly<Record<string, unknown>>,
|
|
243
|
+
): Readonly<Record<string, unknown>> {
|
|
244
|
+
const out: Record<string, unknown> = {};
|
|
245
|
+
for (const [key, value] of Object.entries(values)) {
|
|
246
|
+
out[key] = schema[key]?.secret === true && value !== undefined ? REDACTED : value;
|
|
247
|
+
}
|
|
248
|
+
return Object.freeze(out);
|
|
249
|
+
}
|
|
250
|
+
|
|
234
251
|
export interface EnvVarSummary {
|
|
235
252
|
readonly key: string;
|
|
236
253
|
readonly type: EnvVarType;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Single responsibility: which named environment this process is running as. ONE concept, one
|
|
2
|
+
// key (`ULTIMATE_ENV`), one spelling per value — the twin of `roles.ts`: `ROLE` says what the
|
|
3
|
+
// process does, `ULTIMATE_ENV` says which deploy it belongs to.
|
|
4
|
+
|
|
5
|
+
import { type CodedErrorInit, UltimateError } from './errors';
|
|
6
|
+
|
|
7
|
+
export class EnvironmentInvalidError extends UltimateError {
|
|
8
|
+
static readonly code = 'X_ENVIRONMENT_INVALID';
|
|
9
|
+
override readonly name = 'EnvironmentInvalidError';
|
|
10
|
+
constructor(init: CodedErrorInit) {
|
|
11
|
+
super({ ...init, code: EnvironmentInvalidError.code });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The spellings are `NODE_ENV`'s, plus `staging`. Inventing `dev`/`prod` aliases would mean two
|
|
17
|
+
* ways to write one environment, and `NODE_ENV=production` — which every container image and
|
|
18
|
+
* platform already sets — would have to be translated at every read.
|
|
19
|
+
*/
|
|
20
|
+
export const ENVIRONMENTS = ['development', 'test', 'staging', 'production'] as const;
|
|
21
|
+
|
|
22
|
+
export type Environment = (typeof ENVIRONMENTS)[number];
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_ENVIRONMENT: Environment = 'development';
|
|
25
|
+
|
|
26
|
+
/** The one key. `NODE_ENV` is read only as a fallback, because platforms set it for us. */
|
|
27
|
+
export const ENVIRONMENT_KEY = 'ULTIMATE_ENV';
|
|
28
|
+
|
|
29
|
+
export function isEnvironment(value: unknown): value is Environment {
|
|
30
|
+
return typeof value === 'string' && (ENVIRONMENTS as readonly string[]).includes(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ResolveEnvironmentOptions {
|
|
34
|
+
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
35
|
+
readonly fallback?: Environment | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `ULTIMATE_ENV`, else `NODE_ENV`, else `development`.
|
|
40
|
+
*
|
|
41
|
+
* A set-but-unknown `ULTIMATE_ENV` throws: it is the framework's key, so a typo in it is always a
|
|
42
|
+
* mistake, exactly as `resolveRole()` treats `ROLE`. A set-but-unknown `NODE_ENV` does not throw —
|
|
43
|
+
* it is not ours to police, and CI images set it to values ("ci", "qa") that must not stop a boot.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveEnvironment(options?: ResolveEnvironmentOptions): Environment {
|
|
46
|
+
const source = options?.env ?? (process.env as Record<string, string | undefined>);
|
|
47
|
+
const declared = source[ENVIRONMENT_KEY];
|
|
48
|
+
if (declared !== undefined && declared !== '') {
|
|
49
|
+
if (isEnvironment(declared)) return declared;
|
|
50
|
+
throw new EnvironmentInvalidError({
|
|
51
|
+
cause: `${ENVIRONMENT_KEY}="${declared}" is not one of ${ENVIRONMENTS.join(' | ')}`,
|
|
52
|
+
fix: `export ${ENVIRONMENT_KEY}=${ENVIRONMENTS.join('|')} — one of those exact values`,
|
|
53
|
+
meta: { key: ENVIRONMENT_KEY, received: declared, allowed: ENVIRONMENTS },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const inherited = source['NODE_ENV'];
|
|
57
|
+
if (isEnvironment(inherited)) return inherited;
|
|
58
|
+
return options?.fallback ?? DEFAULT_ENVIRONMENT;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The one production test. Anything that is not literally `production` is not production. */
|
|
62
|
+
export function isProduction(options?: ResolveEnvironmentOptions): boolean {
|
|
63
|
+
return resolveEnvironment(options) === 'production';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* True where a shipped development default (a signing key, a stub driver, an open CORS rule) is
|
|
68
|
+
* still acceptable. `staging` is deliberately NOT included: staging exists to fail the way
|
|
69
|
+
* production fails.
|
|
70
|
+
*/
|
|
71
|
+
export function isLocal(options?: ResolveEnvironmentOptions): boolean {
|
|
72
|
+
const environment = resolveEnvironment(options);
|
|
73
|
+
return environment === 'development' || environment === 'test';
|
|
74
|
+
}
|
package/src/error-codes.ts
CHANGED
|
@@ -32,7 +32,9 @@ const CORE_CODE_TITLES = {
|
|
|
32
32
|
X_CURSOR_INVALID: 'pagination cursor is malformed, tampered with or from another query',
|
|
33
33
|
X_CURSOR_SECRET_DEV: 'cursors are signed with the shipped development key',
|
|
34
34
|
X_DRAINING: 'process is draining and refuses new work',
|
|
35
|
+
X_ENV_EXAMPLE_DRIFT: '.env.example does not declare every variable the schema requires',
|
|
35
36
|
X_ENV_MISSING: 'required environment variables are missing or invalid',
|
|
37
|
+
X_ENVIRONMENT_INVALID: 'ULTIMATE_ENV is not a known environment',
|
|
36
38
|
X_ERROR_CODE_DUPLICATE: 'error code registered twice',
|
|
37
39
|
X_ID_INVALID: 'value is not a valid id',
|
|
38
40
|
X_IMAGE_DECODE_FAILED: 'image bytes are malformed, truncated or internally inconsistent',
|
|
@@ -40,6 +42,8 @@ const CORE_CODE_TITLES = {
|
|
|
40
42
|
X_IMAGE_UNSUPPORTED: 'the built-in image pipeline cannot read or write this format',
|
|
41
43
|
X_INTERNAL: 'unexpected internal framework error',
|
|
42
44
|
X_INVARIANT: 'invariant violated',
|
|
45
|
+
X_METRIC_NAME_INVALID: 'metric name is malformed or already declared with another kind',
|
|
46
|
+
X_METRIC_VALUE_INVALID: 'metric value is not recordable',
|
|
43
47
|
X_NO_CONTEXT: 'no request context is active',
|
|
44
48
|
X_NOT_IMPLEMENTED: 'this driver does not implement the requested feature',
|
|
45
49
|
X_REGISTRAR_CONFLICT: 'two different registrars are loaded for one primitive kind',
|
package/src/index.ts
CHANGED
|
@@ -74,7 +74,28 @@ export type {
|
|
|
74
74
|
EnvVarSummary,
|
|
75
75
|
EnvVarType,
|
|
76
76
|
} from './env';
|
|
77
|
-
export { checkEnv, defineEnv, describeEnv } from './env';
|
|
77
|
+
export { checkEnv, defineEnv, describeEnv, maskedEnvValues } from './env';
|
|
78
|
+
export type { EnvExampleOptions, EnvExampleReport } from './env-example';
|
|
79
|
+
export {
|
|
80
|
+
assertEnvExample,
|
|
81
|
+
checkEnvExample,
|
|
82
|
+
ENV_EXAMPLE_PATH,
|
|
83
|
+
EnvExampleDriftError,
|
|
84
|
+
envFileCandidates,
|
|
85
|
+
parseEnvKeys,
|
|
86
|
+
renderEnvExample,
|
|
87
|
+
} from './env-example';
|
|
88
|
+
export type { Environment, ResolveEnvironmentOptions } from './environment';
|
|
89
|
+
export {
|
|
90
|
+
DEFAULT_ENVIRONMENT,
|
|
91
|
+
ENVIRONMENT_KEY,
|
|
92
|
+
ENVIRONMENTS,
|
|
93
|
+
EnvironmentInvalidError,
|
|
94
|
+
isEnvironment,
|
|
95
|
+
isLocal,
|
|
96
|
+
isProduction,
|
|
97
|
+
resolveEnvironment,
|
|
98
|
+
} from './environment';
|
|
78
99
|
export type {
|
|
79
100
|
CoreErrorCode,
|
|
80
101
|
ErrorCodeDeclaration,
|
|
@@ -210,6 +231,41 @@ export {
|
|
|
210
231
|
redactKeys,
|
|
211
232
|
setLoggerContextFields,
|
|
212
233
|
} from './logger';
|
|
234
|
+
export type {
|
|
235
|
+
Counter,
|
|
236
|
+
Gauge,
|
|
237
|
+
GaugeOptions,
|
|
238
|
+
Histogram,
|
|
239
|
+
HistogramOptions,
|
|
240
|
+
HistogramPoint,
|
|
241
|
+
InstrumentOptions,
|
|
242
|
+
MemoryMetricExporter,
|
|
243
|
+
MetricAttributes,
|
|
244
|
+
MetricAttributeValue,
|
|
245
|
+
MetricCollection,
|
|
246
|
+
MetricDescriptor,
|
|
247
|
+
MetricExporter,
|
|
248
|
+
MetricKind,
|
|
249
|
+
MetricPoint,
|
|
250
|
+
MetricsOptions,
|
|
251
|
+
ReadableMetric,
|
|
252
|
+
} from './metrics';
|
|
253
|
+
export {
|
|
254
|
+
collectMetrics,
|
|
255
|
+
configureMetrics,
|
|
256
|
+
counter,
|
|
257
|
+
DEFAULT_HISTOGRAM_BOUNDS,
|
|
258
|
+
exportMetrics,
|
|
259
|
+
gauge,
|
|
260
|
+
histogram,
|
|
261
|
+
MetricNameInvalidError,
|
|
262
|
+
MetricValueInvalidError,
|
|
263
|
+
memoryMetricExporter,
|
|
264
|
+
noopMetricExporter,
|
|
265
|
+
resetMetrics,
|
|
266
|
+
startMetricExport,
|
|
267
|
+
} from './metrics';
|
|
268
|
+
export { METRICS_CONTENT_TYPE, METRICS_PATH, metricsText } from './metrics-text';
|
|
213
269
|
export type { ModuleRegistrar, PrimitiveKind, RegisteredPrimitive } from './registrar';
|
|
214
270
|
export {
|
|
215
271
|
hasPrimitiveRegistrar,
|
|
@@ -222,6 +278,27 @@ export type { Err, Ok, Result } from './result';
|
|
|
222
278
|
export { err, isErr, isOk, map, mapErr, ok, tryCatch, unwrap, unwrapOr } from './result';
|
|
223
279
|
export type { ResolveRoleOptions, Role, RoleInfo, ScalingSignal } from './roles';
|
|
224
280
|
export { DEFAULT_ROLE, isRole, ROLE_INFO, ROLES, resolveRole } from './roles';
|
|
281
|
+
export type { RequestSample } from './runtime-metrics';
|
|
282
|
+
export {
|
|
283
|
+
connections,
|
|
284
|
+
jobs,
|
|
285
|
+
queueDepth,
|
|
286
|
+
recordConnection,
|
|
287
|
+
recordJob,
|
|
288
|
+
recordQueueDepth,
|
|
289
|
+
recordRequest,
|
|
290
|
+
requestDuration,
|
|
291
|
+
requests,
|
|
292
|
+
SCALING_METRICS,
|
|
293
|
+
} from './runtime-metrics';
|
|
294
|
+
export type { Secret } from './secret';
|
|
295
|
+
export {
|
|
296
|
+
isSecret,
|
|
297
|
+
revealOptionalSecret,
|
|
298
|
+
revealSecret,
|
|
299
|
+
SECRET_BRAND,
|
|
300
|
+
secret,
|
|
301
|
+
} from './secret';
|
|
225
302
|
export type { ServiceFactory } from './service';
|
|
226
303
|
export { defineService, resetServices } from './service';
|
|
227
304
|
export type {
|
|
@@ -248,6 +325,7 @@ export {
|
|
|
248
325
|
noopExporter,
|
|
249
326
|
parseTraceparent,
|
|
250
327
|
resetTelemetry,
|
|
328
|
+
serviceResource,
|
|
251
329
|
startSpan,
|
|
252
330
|
traceparent,
|
|
253
331
|
withSpan,
|
package/src/logger.ts
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
import { type Clock, systemClock } from './clock';
|
|
5
5
|
import { isUltimateError } from './errors';
|
|
6
|
+
import { isSecret, REDACTED } from './secret';
|
|
7
|
+
|
|
8
|
+
// Re-exported, not redefined: `secret.ts` owns the placeholder because a `Secret` has to render
|
|
9
|
+
// it without importing the logger, and two constants spelled the same is one rename from a leak.
|
|
10
|
+
export { REDACTED } from './secret';
|
|
6
11
|
|
|
7
12
|
export const LOG_LEVELS = ['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'silent'] as const;
|
|
8
13
|
|
|
@@ -43,8 +48,6 @@ export interface LoggerOptions {
|
|
|
43
48
|
readonly writer?: ((line: string, level: LogLevel) => void) | undefined;
|
|
44
49
|
}
|
|
45
50
|
|
|
46
|
-
export const REDACTED = '[redacted]';
|
|
47
|
-
|
|
48
51
|
const redactedKeys = new Set<string>([
|
|
49
52
|
'password',
|
|
50
53
|
'token',
|
|
@@ -82,6 +85,9 @@ function defaultWriter(line: string, level: LogLevel): void {
|
|
|
82
85
|
|
|
83
86
|
function serialiseValue(value: unknown, depth: number): unknown {
|
|
84
87
|
if (value === null || typeof value !== 'object') return value;
|
|
88
|
+
// Before every other branch: a `Secret` is redacted by VALUE, so it stays redacted under a key
|
|
89
|
+
// nobody listed — `{ dsn: secret(url) }` is the leak key-name redaction cannot see.
|
|
90
|
+
if (isSecret(value)) return REDACTED;
|
|
85
91
|
if (value instanceof Date) return value.toISOString();
|
|
86
92
|
if (isUltimateError(value)) return value.toJSON();
|
|
87
93
|
if (value instanceof Error) return { name: value.name, message: value.message };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Single responsibility: render a `MetricCollection` as the Prometheus/OpenMetrics text format —
|
|
2
|
+
// the payload behind `/metrics`. A serialisation, not a vendor: it is what a Kubernetes metric
|
|
3
|
+
// adapter, a Grafana Agent, a Datadog agent and an OTel collector all already read (axiom 7).
|
|
4
|
+
|
|
5
|
+
import type { MetricAttributes, MetricPoint, ReadableMetric } from './metrics';
|
|
6
|
+
import { collectMetrics, type HistogramPoint, type MetricCollection } from './metrics';
|
|
7
|
+
|
|
8
|
+
export const METRICS_PATH = '/metrics';
|
|
9
|
+
|
|
10
|
+
export const METRICS_CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8';
|
|
11
|
+
|
|
12
|
+
/** The exposition format escapes exactly these three, and nothing else. */
|
|
13
|
+
function escapeLabel(value: string): string {
|
|
14
|
+
return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function labels(attributes: MetricAttributes, extra?: readonly [string, string]): string {
|
|
18
|
+
const pairs = Object.entries(attributes)
|
|
19
|
+
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
20
|
+
.map(([key, value]) => `${key}="${escapeLabel(String(value))}"`);
|
|
21
|
+
if (extra !== undefined) pairs.push(`${extra[0]}="${escapeLabel(extra[1])}"`);
|
|
22
|
+
return pairs.length === 0 ? '' : `{${pairs.join(',')}}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `+Inf` and `NaN` are spelled out; everything else is plain decimal. */
|
|
26
|
+
function number(value: number): string {
|
|
27
|
+
if (value === Number.POSITIVE_INFINITY) return '+Inf';
|
|
28
|
+
if (value === Number.NEGATIVE_INFINITY) return '-Inf';
|
|
29
|
+
return Number.isNaN(value) ? 'NaN' : String(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isHistogramPoint(point: MetricPoint): point is HistogramPoint {
|
|
33
|
+
return 'buckets' in point;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A histogram renders as three families — `_bucket`, `_sum`, `_count` — with CUMULATIVE bucket
|
|
38
|
+
* counts. Storing them cumulatively instead would make every record a loop over the bounds.
|
|
39
|
+
*/
|
|
40
|
+
function histogramLines(name: string, point: HistogramPoint): readonly string[] {
|
|
41
|
+
const lines: string[] = [];
|
|
42
|
+
let running = 0;
|
|
43
|
+
point.bounds.forEach((bound, index) => {
|
|
44
|
+
running += point.buckets[index] ?? 0;
|
|
45
|
+
lines.push(`${name}_bucket${labels(point.attributes, ['le', number(bound)])} ${running}`);
|
|
46
|
+
});
|
|
47
|
+
running += point.buckets[point.bounds.length] ?? 0;
|
|
48
|
+
lines.push(`${name}_bucket${labels(point.attributes, ['le', '+Inf'])} ${running}`);
|
|
49
|
+
lines.push(`${name}_sum${labels(point.attributes)} ${number(point.value)}`);
|
|
50
|
+
lines.push(`${name}_count${labels(point.attributes)} ${point.count}`);
|
|
51
|
+
return lines;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function metricLines(metric: ReadableMetric): readonly string[] {
|
|
55
|
+
const { name, kind, description, unit } = metric.descriptor;
|
|
56
|
+
const help = unit === '1' || unit === '' ? description : `${description} (${unit})`;
|
|
57
|
+
const lines = [`# HELP ${name} ${escapeLabel(help)}`, `# TYPE ${name} ${kind}`];
|
|
58
|
+
for (const point of metric.points) {
|
|
59
|
+
if (kind === 'histogram' && isHistogramPoint(point)) {
|
|
60
|
+
lines.push(...histogramLines(name, point));
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
lines.push(`${name}${labels(point.attributes)} ${number(point.value)}`);
|
|
64
|
+
}
|
|
65
|
+
return lines;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The scrape body. `target_info` carries the service identity as labels, which is how OTel's own
|
|
70
|
+
* Prometheus mapping does it — the alternative is stamping every series with the same two labels.
|
|
71
|
+
*/
|
|
72
|
+
export function metricsText(collection: MetricCollection = collectMetrics()): string {
|
|
73
|
+
const { serviceName, serviceVersion } = collection.resource;
|
|
74
|
+
const lines = [
|
|
75
|
+
'# HELP target_info the service these metrics describe',
|
|
76
|
+
'# TYPE target_info gauge',
|
|
77
|
+
`target_info{service_name="${escapeLabel(serviceName)}",service_version="${escapeLabel(serviceVersion)}"} 1`,
|
|
78
|
+
...collection.metrics.flatMap(metricLines),
|
|
79
|
+
];
|
|
80
|
+
return `${lines.join('\n')}\n`;
|
|
81
|
+
}
|
package/src/metrics.ts
ADDED
|
Binary file
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Single responsibility: the metrics EVERY Ultimate process emits, named to match what the
|
|
2
|
+
// deploy chart already scales on. One place decides these names — `roles.ts` says what a role
|
|
3
|
+
// scales on, this says which series carries it, and `docker/helm` reads the same three words.
|
|
4
|
+
|
|
5
|
+
import type { Counter, Gauge } from './metrics';
|
|
6
|
+
import { counter, gauge, type Histogram, histogram } from './metrics';
|
|
7
|
+
import type { ScalingSignal } from './roles';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The instrument behind each role's scaling signal.
|
|
11
|
+
*
|
|
12
|
+
* `rps` is a RATE, and a rate is derived, never stored: the process emits a monotonic counter and
|
|
13
|
+
* the adapter differentiates it (`rate(http_requests_total[1m])`), which is also what the chart's
|
|
14
|
+
* own comment ("via the ingress metric adapter") already assumes. The other two are instantaneous
|
|
15
|
+
* values a scrape can read directly, so their series names are the chart's words verbatim.
|
|
16
|
+
*/
|
|
17
|
+
export const SCALING_METRICS: Readonly<Record<ScalingSignal, string | null>> = Object.freeze({
|
|
18
|
+
rps: 'http_requests_total',
|
|
19
|
+
'ws-connections': 'connections',
|
|
20
|
+
'queue-depth': 'queue_depth',
|
|
21
|
+
singleton: null,
|
|
22
|
+
'run-once': null,
|
|
23
|
+
'per-database': null,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const requests: Counter = counter('http_requests_total', {
|
|
27
|
+
unit: '{request}',
|
|
28
|
+
description: 'HTTP requests served, by route, method and status class',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const requestDuration: Histogram = histogram('http_request_duration_seconds', {
|
|
32
|
+
unit: 's',
|
|
33
|
+
description: 'HTTP server request duration',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const connections: Gauge = gauge('connections', {
|
|
37
|
+
unit: '{connection}',
|
|
38
|
+
description: 'Live websocket connections held by this process',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const queueDepth: Gauge = gauge('queue_depth', {
|
|
42
|
+
unit: '{job}',
|
|
43
|
+
description: 'Jobs waiting to be picked up, by queue',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export const jobs: Counter = counter('jobs_total', {
|
|
47
|
+
unit: '{job}',
|
|
48
|
+
description: 'Background jobs finished, by queue and outcome',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export interface RequestSample {
|
|
52
|
+
readonly method: string;
|
|
53
|
+
/** The route PATTERN (`/posts/:id`), never the concrete path — one series per pattern. */
|
|
54
|
+
readonly route: string;
|
|
55
|
+
readonly status: number;
|
|
56
|
+
readonly durationMs: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One call per served request, from the HTTP pipeline. `status` becomes a status CLASS (`2xx`)
|
|
61
|
+
* because a label per status code multiplies every series by the number of codes an app can
|
|
62
|
+
* return, and no autoscaler asks the difference between 201 and 204.
|
|
63
|
+
*/
|
|
64
|
+
export function recordRequest(sample: RequestSample): void {
|
|
65
|
+
const attributes = {
|
|
66
|
+
method: sample.method,
|
|
67
|
+
route: sample.route,
|
|
68
|
+
status: `${Math.floor(sample.status / 100)}xx`,
|
|
69
|
+
};
|
|
70
|
+
requests.add(1, attributes);
|
|
71
|
+
requestDuration.record(sample.durationMs / 1000, attributes);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `+1` on connect, `-1` on close, from the realtime transport. */
|
|
75
|
+
export function recordConnection(delta: number): void {
|
|
76
|
+
connections.add(delta);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Absolute depth, from the worker's own queue read. */
|
|
80
|
+
export function recordQueueDepth(queue: string, depth: number): void {
|
|
81
|
+
queueDepth.record(depth, { queue });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function recordJob(queue: string, outcome: 'ok' | 'failed' | 'dead'): void {
|
|
85
|
+
jobs.add(1, { queue, outcome });
|
|
86
|
+
}
|
package/src/secret.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Single responsibility: a value that cannot be printed by accident. Key-name redaction only
|
|
2
|
+
// catches a secret travelling under a name someone remembered to list; a `Secret` box redacts by
|
|
3
|
+
// VALUE, so the same string is safe in a log line, an error `meta`, a manifest and a snapshot.
|
|
4
|
+
|
|
5
|
+
export const REDACTED = '[redacted]';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Structural brand, not `instanceof`: two copies of `@ultimat3/core` in one dependency tree must
|
|
9
|
+
* still recognise each other's secrets, or redaction silently stops applying at the seam.
|
|
10
|
+
*/
|
|
11
|
+
export const SECRET_BRAND: unique symbol = Symbol.for('ultimate.secret');
|
|
12
|
+
|
|
13
|
+
/** Bun and Node both honour this when rendering a value in `console.log`. */
|
|
14
|
+
const INSPECT = Symbol.for('nodejs.util.inspect.custom');
|
|
15
|
+
|
|
16
|
+
export interface Secret {
|
|
17
|
+
readonly [SECRET_BRAND]: true;
|
|
18
|
+
/** Non-secret name for the value — the env key, the config field. Safe to print. */
|
|
19
|
+
readonly label: string;
|
|
20
|
+
toString(): string;
|
|
21
|
+
toJSON(): string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface SecretInternal extends Secret {
|
|
25
|
+
reveal(): string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const hidden = (value: unknown): PropertyDescriptor => ({
|
|
29
|
+
value,
|
|
30
|
+
enumerable: false,
|
|
31
|
+
writable: false,
|
|
32
|
+
configurable: false,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Box a value so every serialisation path renders `[redacted]`: `String()`, template literals,
|
|
37
|
+
* `+`, `JSON.stringify`, `console.log`, and the logger. `revealSecret()` is the only way out, and
|
|
38
|
+
* it is a free function on purpose — one greppable call site per place a secret is actually used.
|
|
39
|
+
*
|
|
40
|
+
* Everything except `label` is non-enumerable, so `{ ...token }`, `Object.entries(token)` and a
|
|
41
|
+
* structured-clone of it cannot carry the value back out of the box.
|
|
42
|
+
*/
|
|
43
|
+
export function secret(value: string, label = 'secret'): Secret {
|
|
44
|
+
const boxed = Object.defineProperties({ label } as { label: string }, {
|
|
45
|
+
[SECRET_BRAND]: hidden(true),
|
|
46
|
+
reveal: hidden(() => value),
|
|
47
|
+
toString: hidden(() => REDACTED),
|
|
48
|
+
toJSON: hidden(() => REDACTED),
|
|
49
|
+
[INSPECT]: hidden(() => REDACTED),
|
|
50
|
+
[Symbol.toPrimitive]: hidden(() => REDACTED),
|
|
51
|
+
}) as unknown as SecretInternal;
|
|
52
|
+
return Object.freeze(boxed);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isSecret(value: unknown): value is Secret {
|
|
56
|
+
return typeof value === 'object' && value !== null && SECRET_BRAND in value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The one documented way to read a secret. Every call site is a place worth reviewing. */
|
|
60
|
+
export function revealSecret(value: Secret): string {
|
|
61
|
+
return (value as SecretInternal).reveal();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Reveal only when there is something to reveal — for optional configuration. */
|
|
65
|
+
export function revealOptionalSecret(value: Secret | undefined): string | undefined {
|
|
66
|
+
return value === undefined ? undefined : revealSecret(value);
|
|
67
|
+
}
|
package/src/telemetry.ts
CHANGED
|
@@ -139,6 +139,14 @@ export function resetTelemetry(): void {
|
|
|
139
139
|
enabled = true;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* The service identity every signal carries. One resource for spans and metrics alike, as OTel
|
|
144
|
+
* defines it — a metric that named a different service than its own traces is unjoinable.
|
|
145
|
+
*/
|
|
146
|
+
export function serviceResource(): SpanResource {
|
|
147
|
+
return resource;
|
|
148
|
+
}
|
|
149
|
+
|
|
142
150
|
export function currentSpan(): Span | undefined {
|
|
143
151
|
return activeSpan.getStore();
|
|
144
152
|
}
|