@arcote.tech/arc-otel 0.7.6
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 +246 -0
- package/dist/context-propagation.d.ts +29 -0
- package/dist/context-propagation.d.ts.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +362 -0
- package/dist/init-browser.d.ts +8 -0
- package/dist/init-browser.d.ts.map +1 -0
- package/dist/init-browser.js +326 -0
- package/dist/init-server.d.ts +8 -0
- package/dist/init-server.d.ts.map +1 -0
- package/dist/init-server.js +339 -0
- package/dist/sanitize.d.ts +39 -0
- package/dist/sanitize.d.ts.map +1 -0
- package/dist/telemetry.d.ts +99 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/wrap-db-adapter.d.ts +28 -0
- package/dist/wrap-db-adapter.d.ts.map +1 -0
- package/package.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# @arcote.tech/arc-otel
|
|
2
|
+
|
|
3
|
+
OpenTelemetry instrumentation primitives for the Arc framework. Wraps the
|
|
4
|
+
core OTel SDKs (`@opentelemetry/*`) behind an Arc-friendly API with
|
|
5
|
+
PII-safe defaults, dev/prod sampling modes, and W3C Trace Context
|
|
6
|
+
propagation.
|
|
7
|
+
|
|
8
|
+
This package is **optional**. Arc apps run identically whether you import
|
|
9
|
+
it or not — every span call short-circuits when no SDK is attached. Opt
|
|
10
|
+
in by setting `observability.enabled: true` in `deploy.arc.json` (or by
|
|
11
|
+
exporting `ARC_OTEL_ENABLED=true` for ad-hoc local runs).
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Architecture
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
┌─ browser ───────────────────────────────────────────┐
|
|
19
|
+
│ start-app.ts │
|
|
20
|
+
│ └─ if (window.__ARC_OTEL_CONFIG) │
|
|
21
|
+
│ await import("@arcote.tech/arc-otel/browser") │
|
|
22
|
+
│ initBrowserTelemetry({...}) │
|
|
23
|
+
└─────────────────────────────────────────────────────┘
|
|
24
|
+
│
|
|
25
|
+
│ OTLP/HTTP ──/otel/v1/traces──┐
|
|
26
|
+
▼ ▼
|
|
27
|
+
┌─ arc-prod container ─────────┐ ┌─ otel-collector ────┐
|
|
28
|
+
│ startPlatformServer() │ OTLP │ receivers.otlp │
|
|
29
|
+
│ └─ initServerTelemetry({…}) ├────────▶│ processors: │
|
|
30
|
+
│ traces + logs + metrics │ │ - tail_sampling │
|
|
31
|
+
│ createArcServer({telemetry})│ │ - attributes │
|
|
32
|
+
│ └─ HTTP span │ │ - batch │
|
|
33
|
+
│ └─ WS message span │ │ exporters: │
|
|
34
|
+
│ └─ command.<name> span │ │ - tempo (traces) │
|
|
35
|
+
│ └─ db.find/set/commit span │ │ - loki (logs) │
|
|
36
|
+
└──────────────────────────────┘ │ - prom (metrics) │
|
|
37
|
+
└────────┬────────────┘
|
|
38
|
+
│
|
|
39
|
+
┌──────────────────┼────────────────┐
|
|
40
|
+
▼ ▼ ▼
|
|
41
|
+
┌─ tempo ────┐ ┌─ loki ─────┐ ┌─ prometheus ┐
|
|
42
|
+
│ 7d traces │ │ 7d logs │ │ 30d metrics │
|
|
43
|
+
└────────────┘ └────────────┘ └─────────────┘
|
|
44
|
+
\ | /
|
|
45
|
+
\ | /
|
|
46
|
+
▼ ▼ ▼
|
|
47
|
+
┌─ grafana (HTTPS via Caddy basic-auth) ─┐
|
|
48
|
+
│ observability.<apex-of-app-domain> │
|
|
49
|
+
│ admin / ARC_OBSERVABILITY_PASSWORD │
|
|
50
|
+
└────────────────────────────────────────┘
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Quick start (production deploy)
|
|
56
|
+
|
|
57
|
+
Opt-in via `deploy.arc.json`:
|
|
58
|
+
|
|
59
|
+
```jsonc
|
|
60
|
+
{
|
|
61
|
+
"target": {...},
|
|
62
|
+
"envs": {
|
|
63
|
+
"prod": { "domain": "app.example.com", "db": { "type": "postgres" } }
|
|
64
|
+
},
|
|
65
|
+
"caddy": { "email": "ops@example.com" },
|
|
66
|
+
"registry": { "domain": "registry.example.com", "passwordEnv": "ARC_REGISTRY_PASSWORD" },
|
|
67
|
+
|
|
68
|
+
"observability": {
|
|
69
|
+
"enabled": true
|
|
70
|
+
// optional:
|
|
71
|
+
// "subdomain": "observability",
|
|
72
|
+
// "adminPasswordEnv": "ARC_OBSERVABILITY_PASSWORD",
|
|
73
|
+
// "retention": { "traces": "168h", "logs": "168h", "metrics": "30d" }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Then:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
arc platform deploy prod
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
First deploy auto-generates `ARC_OBSERVABILITY_PASSWORD` into
|
|
85
|
+
`deploy.arc.env` (gitignored) and provisions five sidecar containers:
|
|
86
|
+
`otel-collector`, `tempo`, `loki`, `prometheus`, `grafana`. Grafana is
|
|
87
|
+
reachable at `https://observability.<apex>/` behind Caddy basic-auth
|
|
88
|
+
(`admin` / generated password).
|
|
89
|
+
|
|
90
|
+
Existing deploys that don't set `observability` are unchanged — no new
|
|
91
|
+
containers, no env-vars on the app, zero added latency.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## What's instrumented
|
|
96
|
+
|
|
97
|
+
### Server (always when `ARC_OTEL_ENABLED=true`)
|
|
98
|
+
|
|
99
|
+
| Span name | Source | Notable attrs |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| `${METHOD} ${PATH}` | arc-host fetch handler | `http.request.method`, `http.route`, `http.response.status_code` |
|
|
102
|
+
| `ws.${type}` | arc-host websocket dispatch | `messaging.message.type`, `arc.ws.client_id` |
|
|
103
|
+
| `command.${name}` | ContextHandler.executeCommand | `rpc.system=arc`, `arc.command.name`, `arc.command.params_size`, payload in dev only |
|
|
104
|
+
| `db.${op} ${store}` | wrapDbAdapter (postgres/sqlite) | `db.system`, `db.operation.name`, `db.collection.name`, `db.response.row_count` |
|
|
105
|
+
|
|
106
|
+
Server emits **metrics** automatically:
|
|
107
|
+
|
|
108
|
+
| Metric | Type | Labels |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| `arc.commands.total` | Counter | `arc.command.name` |
|
|
111
|
+
| `arc.command.duration_ms` | Histogram | `arc.command.name` |
|
|
112
|
+
| `arc.db.find_ms` | Histogram | `db.system`, `db.collection.name` |
|
|
113
|
+
|
|
114
|
+
### Browser (when `window.__ARC_OTEL_CONFIG` is present)
|
|
115
|
+
|
|
116
|
+
- W3C Trace Context propagator registered globally — outbound `fetch`
|
|
117
|
+
calls automatically attach `traceparent` / `tracestate` headers so the
|
|
118
|
+
server's HTTP span can pick up the client trace.
|
|
119
|
+
- SDK chunk is `import()`-ed on demand from `start-app.ts`, so initial
|
|
120
|
+
bundle size stays untouched when observability is off.
|
|
121
|
+
- Anonymous session id stored in `sessionStorage` (`arc:otel-session-id`)
|
|
122
|
+
groups spans per tab — never use as a user identifier.
|
|
123
|
+
|
|
124
|
+
> Per-hook spans (`useQuery`, `useMutation`) and WS-frame trace context
|
|
125
|
+
> injection are **deliberately out of scope** for v1. The server already
|
|
126
|
+
> wraps every command/query in a span that's parented to the HTTP route,
|
|
127
|
+
> which gives end-to-end visibility for traffic that originates from
|
|
128
|
+
> `fetch` calls. WS traceparent propagation is a follow-up — when added,
|
|
129
|
+
> call `injectTraceContext` on outbound messages.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Sampling
|
|
134
|
+
|
|
135
|
+
The SDK is configured `parentbased_always_on` on the server and
|
|
136
|
+
`traceIdRatioBased(0.1)` on the browser by default. Final decisions are
|
|
137
|
+
made by the **collector's tail sampler**, evaluated 10s after the trace
|
|
138
|
+
finishes:
|
|
139
|
+
|
|
140
|
+
1. **All errors** are kept (any span with status_code=ERROR).
|
|
141
|
+
2. **All slow traces** are kept (duration > 500 ms).
|
|
142
|
+
3. **10% of everything else** is kept (random).
|
|
143
|
+
|
|
144
|
+
This guarantees you'll never miss a failure or a latency outlier, while
|
|
145
|
+
typical happy-path traffic costs ~10% of total trace bandwidth.
|
|
146
|
+
|
|
147
|
+
Tune in `observability-configs.ts:generateOtelCollectorConfig` — edit
|
|
148
|
+
the `tail_sampling` block's policies (full spec in the OTel collector docs).
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## PII safety
|
|
153
|
+
|
|
154
|
+
Span attributes go through `sanitizeAttrs()` by default, which:
|
|
155
|
+
|
|
156
|
+
- Drops any key matching `(password|token|secret|authorization|jwt|api_key|cookie|email|credit_card|ssn)` (case-insensitive, recursive).
|
|
157
|
+
- Truncates strings longer than 2 KB.
|
|
158
|
+
- Truncates serialized objects longer than 4 KB.
|
|
159
|
+
- Catches circular references → `"[circular]"`.
|
|
160
|
+
|
|
161
|
+
`shouldIncludePayloads()` defaults to **true in development, false in
|
|
162
|
+
production**. Instrumentation sites that attach raw mutation params
|
|
163
|
+
gate on this flag — so a prod span shows `arc.command.params_size`,
|
|
164
|
+
while a dev span shows the (sanitized) payload itself.
|
|
165
|
+
|
|
166
|
+
DATABASE_URL and similar credentials passing through error messages
|
|
167
|
+
are run through `redactConnectionString()`.
|
|
168
|
+
|
|
169
|
+
**Forbidden** (never attach as a span attribute):
|
|
170
|
+
- Raw JWT / rawToken
|
|
171
|
+
- `TokenPayload.params` in full — only `tokenType` + sanitized id-like params
|
|
172
|
+
- Full event payload in prod
|
|
173
|
+
- Raw DB error parameters
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Local development
|
|
178
|
+
|
|
179
|
+
For a fast feedback loop without the full sidecar stack:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
# Terminal 1 — Jaeger all-in-one (only traces, no logs/metrics).
|
|
183
|
+
docker run --rm -d --name jaeger \
|
|
184
|
+
-p 16686:16686 -p 4318:4318 \
|
|
185
|
+
cr.jaegertracing.io/jaegertracing/all-in-one:1.62
|
|
186
|
+
|
|
187
|
+
# Terminal 2 — your app
|
|
188
|
+
export ARC_OTEL_ENABLED=true
|
|
189
|
+
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
|
190
|
+
arc platform dev
|
|
191
|
+
|
|
192
|
+
# Open http://localhost:16686 — service "arc-<env>"
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Browser spans need `window.__ARC_OTEL_CONFIG` injected by
|
|
196
|
+
`generateShellHtml`, which fires when the server has
|
|
197
|
+
`ARC_OTEL_ENABLED=true` — so a single env var enables both sides.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## API surface
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
import {
|
|
205
|
+
ArcTelemetry,
|
|
206
|
+
sanitizeAttrs,
|
|
207
|
+
redactConnectionString,
|
|
208
|
+
injectTraceContext,
|
|
209
|
+
extractTraceContext,
|
|
210
|
+
contextFromHeaders,
|
|
211
|
+
wrapDbAdapter,
|
|
212
|
+
} from "@arcote.tech/arc-otel";
|
|
213
|
+
|
|
214
|
+
import { initServerTelemetry } from "@arcote.tech/arc-otel/server";
|
|
215
|
+
import { initBrowserTelemetry } from "@arcote.tech/arc-otel/browser";
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Key methods on `ArcTelemetry`:
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
// Wrap an async function in a span; auto-records exceptions + sets status.
|
|
222
|
+
await telemetry.startSpan(name, async (span) => {...}, { kind, attributes });
|
|
223
|
+
|
|
224
|
+
// Counter / histogram / log API mirrors OTel's.
|
|
225
|
+
telemetry.incrementCounter("arc.foo.total", 1, { label: "x" });
|
|
226
|
+
telemetry.recordHistogram("arc.foo.duration_ms", elapsed, {...});
|
|
227
|
+
telemetry.log("info", "message", { attr: "value" });
|
|
228
|
+
|
|
229
|
+
// Bridge inbound trace context (HTTP headers, WS frame fields) before
|
|
230
|
+
// starting the active span.
|
|
231
|
+
telemetry.runWithExtractedContext(req.headers, () =>
|
|
232
|
+
telemetry.startSpan("http.request", async () => {...}),
|
|
233
|
+
);
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## Out of scope
|
|
239
|
+
|
|
240
|
+
- Alerting (Grafana alerts + notifier config).
|
|
241
|
+
- Long-term storage (S3/GCS) for traces and logs — needs cloud creds.
|
|
242
|
+
- Continuous profiling (Pyroscope/Parca).
|
|
243
|
+
- Synthetic uptime monitoring.
|
|
244
|
+
- Per-hook React span instrumentation (planned follow-up).
|
|
245
|
+
- WS frame traceparent injection on the client (helper exists,
|
|
246
|
+
integration with EventWire/CommandWire pending).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type Context, type Span } from "@opentelemetry/api";
|
|
2
|
+
/** Standard W3C headers we care about. Useful as a constant for HTTP setup. */
|
|
3
|
+
export declare const TRACE_CONTEXT_HEADERS: readonly ["traceparent", "tracestate"];
|
|
4
|
+
/**
|
|
5
|
+
* Inject the current active span's trace context into `carrier`.
|
|
6
|
+
*
|
|
7
|
+
* Use case: serializing a WS frame payload. The receiver passes the same
|
|
8
|
+
* carrier to `extractTraceContext` and runs the handler inside that context.
|
|
9
|
+
*/
|
|
10
|
+
export declare function injectTraceContext(carrier?: Record<string, string>): Record<string, string>;
|
|
11
|
+
/**
|
|
12
|
+
* Extract a parent trace context from `carrier` and return it. Run handler
|
|
13
|
+
* code via `context.with(parentCtx, () => ...)` or `telemetry.withSpan` so
|
|
14
|
+
* spans created inside link back to the parent automatically.
|
|
15
|
+
*
|
|
16
|
+
* Returns the current context unchanged when no traceparent is present —
|
|
17
|
+
* fine for unauthenticated probes or older clients without instrumentation.
|
|
18
|
+
*/
|
|
19
|
+
export declare function extractTraceContext(carrier: Record<string, unknown>): Context;
|
|
20
|
+
/**
|
|
21
|
+
* Pull the parent span out of an arbitrary headers-like object (lowercased
|
|
22
|
+
* keys) and return it as an OTel-ready context. Convenience wrapper used by
|
|
23
|
+
* the HTTP server handler.
|
|
24
|
+
*/
|
|
25
|
+
export declare function contextFromHeaders(headers: Headers | Record<string, string | string[] | undefined>): Context;
|
|
26
|
+
/** Get the currently active span. Convenience re-export so callers don't
|
|
27
|
+
* need to depend on @opentelemetry/api directly. */
|
|
28
|
+
export declare function getActiveSpan(): Span | undefined;
|
|
29
|
+
//# sourceMappingURL=context-propagation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"context-propagation.d.ts","sourceRoot":"","sources":["../src/context-propagation.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,OAAO,EACZ,KAAK,IAAI,EACV,MAAM,oBAAoB,CAAC;AAY5B,+EAA+E;AAC/E,eAAO,MAAM,qBAAqB,wCAAyC,CAAC;AAE5E;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAG/F;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAE7E;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAC,GAAG,OAAO,CAe5G;AAED;qDACqD;AACrD,wBAAgB,aAAa,IAAI,IAAI,GAAG,SAAS,CAEhD"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { ArcTelemetry, type ObservabilityMode, type SpanOptions, type TelemetryConfig, } from "./telemetry";
|
|
2
|
+
export { sanitizeAttrs, redactConnectionString, DEFAULT_REDACT_KEY_PATTERN, DEFAULT_MAX_STRING_LEN, DEFAULT_MAX_JSON_LEN, type SanitizeOptions, } from "./sanitize";
|
|
3
|
+
export { injectTraceContext, extractTraceContext, contextFromHeaders, getActiveSpan, TRACE_CONTEXT_HEADERS, } from "./context-propagation";
|
|
4
|
+
export { wrapDbAdapter } from "./wrap-db-adapter";
|
|
5
|
+
export { SpanKind, SpanStatusCode } from "@opentelemetry/api";
|
|
6
|
+
export type { Attributes, Span } from "@opentelemetry/api";
|
|
7
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAMA,OAAO,EACL,YAAY,EACZ,KAAK,iBAAiB,EACtB,KAAK,WAAW,EAChB,KAAK,eAAe,GACrB,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,aAAa,EACb,sBAAsB,EACtB,0BAA0B,EAC1B,sBAAsB,EACtB,oBAAoB,EACpB,KAAK,eAAe,GACrB,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,kBAAkB,EAClB,mBAAmB,EACnB,kBAAkB,EAClB,aAAa,EACb,qBAAqB,GACtB,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAGlD,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAC9D,YAAY,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// src/telemetry.ts
|
|
2
|
+
import {
|
|
3
|
+
context,
|
|
4
|
+
propagation,
|
|
5
|
+
SpanStatusCode,
|
|
6
|
+
trace
|
|
7
|
+
} from "@opentelemetry/api";
|
|
8
|
+
import {
|
|
9
|
+
logs,
|
|
10
|
+
SeverityNumber
|
|
11
|
+
} from "@opentelemetry/api-logs";
|
|
12
|
+
|
|
13
|
+
// src/sanitize.ts
|
|
14
|
+
var DEFAULT_REDACT_KEY_PATTERN = /(password|passwd|token|secret|authorization|jwt|api[_-]?key|cookie|email|credit[_-]?card|ssn)/i;
|
|
15
|
+
var DEFAULT_MAX_STRING_LEN = 2048;
|
|
16
|
+
var DEFAULT_MAX_JSON_LEN = 4096;
|
|
17
|
+
function sanitizeAttrs(input, opts = {}) {
|
|
18
|
+
if (!input)
|
|
19
|
+
return {};
|
|
20
|
+
const redactPattern = opts.redactKeyPattern ?? DEFAULT_REDACT_KEY_PATTERN;
|
|
21
|
+
const maxStr = opts.maxStringLen ?? DEFAULT_MAX_STRING_LEN;
|
|
22
|
+
const maxJson = opts.maxJsonLen ?? DEFAULT_MAX_JSON_LEN;
|
|
23
|
+
const out = {};
|
|
24
|
+
for (const [key, raw] of Object.entries(input)) {
|
|
25
|
+
if (redactPattern.test(key))
|
|
26
|
+
continue;
|
|
27
|
+
const value = sanitizeValue(raw, redactPattern, maxStr, maxJson);
|
|
28
|
+
if (value !== undefined)
|
|
29
|
+
out[key] = value;
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
function sanitizeValue(raw, redactPattern, maxStr, maxJson) {
|
|
34
|
+
if (raw === null || raw === undefined)
|
|
35
|
+
return;
|
|
36
|
+
if (typeof raw === "boolean" || typeof raw === "number")
|
|
37
|
+
return raw;
|
|
38
|
+
if (typeof raw === "string") {
|
|
39
|
+
return raw.length > maxStr ? `${raw.slice(0, maxStr)}…(truncated:${raw.length})` : raw;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const filtered = filterRedacted(raw, redactPattern);
|
|
43
|
+
const json = JSON.stringify(filtered);
|
|
44
|
+
if (json === undefined)
|
|
45
|
+
return;
|
|
46
|
+
return json.length > maxJson ? `${json.slice(0, maxJson)}…(truncated:${json.length})` : json;
|
|
47
|
+
} catch {
|
|
48
|
+
return "[unserializable]";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function filterRedacted(node, pattern, seen = new WeakSet) {
|
|
52
|
+
if (node === null || typeof node !== "object")
|
|
53
|
+
return node;
|
|
54
|
+
if (seen.has(node))
|
|
55
|
+
return "[circular]";
|
|
56
|
+
seen.add(node);
|
|
57
|
+
if (Array.isArray(node)) {
|
|
58
|
+
return node.map((v) => filterRedacted(v, pattern, seen));
|
|
59
|
+
}
|
|
60
|
+
const out = {};
|
|
61
|
+
for (const [k, v] of Object.entries(node)) {
|
|
62
|
+
if (pattern.test(k))
|
|
63
|
+
continue;
|
|
64
|
+
out[k] = filterRedacted(v, pattern, seen);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
function redactConnectionString(url) {
|
|
69
|
+
if (!url)
|
|
70
|
+
return "";
|
|
71
|
+
try {
|
|
72
|
+
const u = new URL(url);
|
|
73
|
+
if (u.password)
|
|
74
|
+
u.password = "***";
|
|
75
|
+
return u.toString();
|
|
76
|
+
} catch {
|
|
77
|
+
return "[unparseable]";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/telemetry.ts
|
|
82
|
+
class ArcTelemetry {
|
|
83
|
+
config;
|
|
84
|
+
tracer = null;
|
|
85
|
+
logger = null;
|
|
86
|
+
meter = null;
|
|
87
|
+
histograms = new Map;
|
|
88
|
+
counters = new Map;
|
|
89
|
+
constructor(config) {
|
|
90
|
+
const mode = config.mode ?? "development";
|
|
91
|
+
const enabled = config.enabled ?? mode !== "disabled";
|
|
92
|
+
const sampleRate = config.sampleRate ?? (config.environment === "server" ? 1 : 0.1);
|
|
93
|
+
this.config = {
|
|
94
|
+
...config,
|
|
95
|
+
enabled,
|
|
96
|
+
sampleRate,
|
|
97
|
+
mode,
|
|
98
|
+
debug: config.debug ?? false
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
attach(opts) {
|
|
102
|
+
this.tracer = opts.tracer;
|
|
103
|
+
this.logger = opts.logger ?? null;
|
|
104
|
+
this.meter = opts.meter ?? null;
|
|
105
|
+
}
|
|
106
|
+
get active() {
|
|
107
|
+
return this.config.enabled && this.tracer !== null;
|
|
108
|
+
}
|
|
109
|
+
shouldIncludePayloads() {
|
|
110
|
+
if (this.config.includePayloads !== undefined)
|
|
111
|
+
return this.config.includePayloads;
|
|
112
|
+
return this.config.mode === "development";
|
|
113
|
+
}
|
|
114
|
+
async startSpan(name, fn, options = {}) {
|
|
115
|
+
if (!this.active || !this.tracer) {
|
|
116
|
+
return fn(trace.getActiveSpan() ?? noopSpan());
|
|
117
|
+
}
|
|
118
|
+
const attributes = this.toAttributes(options.attributes, options.unsafeAttrs);
|
|
119
|
+
return this.tracer.startActiveSpan(name, { kind: options.kind, attributes }, async (span) => {
|
|
120
|
+
try {
|
|
121
|
+
const result = await fn(span);
|
|
122
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
123
|
+
return result;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
this.recordError(span, error);
|
|
126
|
+
throw error;
|
|
127
|
+
} finally {
|
|
128
|
+
span.end();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
createSpan(name, options = {}) {
|
|
133
|
+
if (!this.active || !this.tracer)
|
|
134
|
+
return noopSpan();
|
|
135
|
+
const attributes = this.toAttributes(options.attributes, options.unsafeAttrs);
|
|
136
|
+
return this.tracer.startSpan(name, { kind: options.kind, attributes });
|
|
137
|
+
}
|
|
138
|
+
getCurrentSpan() {
|
|
139
|
+
return trace.getActiveSpan();
|
|
140
|
+
}
|
|
141
|
+
withSpan(parent, fn) {
|
|
142
|
+
return context.with(trace.setSpan(context.active(), parent), fn);
|
|
143
|
+
}
|
|
144
|
+
runWithExtractedContext(carrier, fn) {
|
|
145
|
+
if (!this.active)
|
|
146
|
+
return fn();
|
|
147
|
+
const flat = {};
|
|
148
|
+
if (typeof Headers !== "undefined" && carrier instanceof Headers) {
|
|
149
|
+
carrier.forEach((value, key) => {
|
|
150
|
+
flat[key.toLowerCase()] = value;
|
|
151
|
+
});
|
|
152
|
+
} else if (carrier) {
|
|
153
|
+
for (const [k, v] of Object.entries(carrier)) {
|
|
154
|
+
if (typeof v === "string")
|
|
155
|
+
flat[k.toLowerCase()] = v;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const parent = propagation.extract(context.active(), flat);
|
|
159
|
+
return context.with(parent, fn);
|
|
160
|
+
}
|
|
161
|
+
recordError(span, error) {
|
|
162
|
+
const err = error instanceof Error ? error : new Error(String(error ?? "unknown error"));
|
|
163
|
+
try {
|
|
164
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
|
|
165
|
+
span.recordException(err);
|
|
166
|
+
} catch {}
|
|
167
|
+
}
|
|
168
|
+
addAttributes(attrs, unsafeAttrs = false) {
|
|
169
|
+
const span = this.getCurrentSpan();
|
|
170
|
+
if (!span)
|
|
171
|
+
return;
|
|
172
|
+
span.setAttributes(this.toAttributes(attrs, unsafeAttrs));
|
|
173
|
+
}
|
|
174
|
+
log(level, body, attrs = {}, unsafeAttrs = false) {
|
|
175
|
+
if (!this.active)
|
|
176
|
+
return;
|
|
177
|
+
const logger = this.logger ?? logs.getLogger(this.config.serviceName);
|
|
178
|
+
const record = {
|
|
179
|
+
severityNumber: severityFor(level),
|
|
180
|
+
severityText: level.toUpperCase(),
|
|
181
|
+
body,
|
|
182
|
+
attributes: this.toAttributes(attrs, unsafeAttrs)
|
|
183
|
+
};
|
|
184
|
+
try {
|
|
185
|
+
logger.emit(record);
|
|
186
|
+
} catch {}
|
|
187
|
+
}
|
|
188
|
+
incrementCounter(name, value = 1, attrs = {}) {
|
|
189
|
+
if (!this.active || !this.meter)
|
|
190
|
+
return;
|
|
191
|
+
let counter = this.counters.get(name);
|
|
192
|
+
if (!counter) {
|
|
193
|
+
counter = this.meter.createCounter(name);
|
|
194
|
+
this.counters.set(name, counter);
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
counter.add(value, attrs);
|
|
198
|
+
} catch {}
|
|
199
|
+
}
|
|
200
|
+
recordHistogram(name, value, attrs = {}) {
|
|
201
|
+
if (!this.active || !this.meter)
|
|
202
|
+
return;
|
|
203
|
+
let histogram = this.histograms.get(name);
|
|
204
|
+
if (!histogram) {
|
|
205
|
+
histogram = this.meter.createHistogram(name, { unit: "ms" });
|
|
206
|
+
this.histograms.set(name, histogram);
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
histogram.record(value, attrs);
|
|
210
|
+
} catch {}
|
|
211
|
+
}
|
|
212
|
+
measureSince(name, start, attrs = {}) {
|
|
213
|
+
this.recordHistogram(name, Date.now() - start, attrs);
|
|
214
|
+
}
|
|
215
|
+
toAttributes(raw, unsafe) {
|
|
216
|
+
if (!raw)
|
|
217
|
+
return {};
|
|
218
|
+
if (unsafe)
|
|
219
|
+
return raw;
|
|
220
|
+
return sanitizeAttrs(raw, this.config.sanitize);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function severityFor(level) {
|
|
224
|
+
switch (level) {
|
|
225
|
+
case "debug":
|
|
226
|
+
return SeverityNumber.DEBUG;
|
|
227
|
+
case "info":
|
|
228
|
+
return SeverityNumber.INFO;
|
|
229
|
+
case "warn":
|
|
230
|
+
return SeverityNumber.WARN;
|
|
231
|
+
case "error":
|
|
232
|
+
return SeverityNumber.ERROR;
|
|
233
|
+
default:
|
|
234
|
+
return SeverityNumber.INFO;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function noopSpan() {
|
|
238
|
+
return trace.getActiveSpan() ?? trace.wrapSpanContext({
|
|
239
|
+
traceId: "00000000000000000000000000000000",
|
|
240
|
+
spanId: "0000000000000000",
|
|
241
|
+
traceFlags: 0
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
// src/context-propagation.ts
|
|
245
|
+
import {
|
|
246
|
+
context as context2,
|
|
247
|
+
propagation as propagation2,
|
|
248
|
+
trace as trace2
|
|
249
|
+
} from "@opentelemetry/api";
|
|
250
|
+
var TRACE_CONTEXT_HEADERS = ["traceparent", "tracestate"];
|
|
251
|
+
function injectTraceContext(carrier = {}) {
|
|
252
|
+
propagation2.inject(context2.active(), carrier);
|
|
253
|
+
return carrier;
|
|
254
|
+
}
|
|
255
|
+
function extractTraceContext(carrier) {
|
|
256
|
+
return propagation2.extract(context2.active(), carrier);
|
|
257
|
+
}
|
|
258
|
+
function contextFromHeaders(headers) {
|
|
259
|
+
const carrier = {};
|
|
260
|
+
if (headers instanceof Headers) {
|
|
261
|
+
for (const name of TRACE_CONTEXT_HEADERS) {
|
|
262
|
+
const value = headers.get(name);
|
|
263
|
+
if (value)
|
|
264
|
+
carrier[name] = value;
|
|
265
|
+
}
|
|
266
|
+
} else {
|
|
267
|
+
for (const name of TRACE_CONTEXT_HEADERS) {
|
|
268
|
+
const raw = headers[name];
|
|
269
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
270
|
+
if (value)
|
|
271
|
+
carrier[name] = value;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return extractTraceContext(carrier);
|
|
275
|
+
}
|
|
276
|
+
function getActiveSpan() {
|
|
277
|
+
return trace2.getActiveSpan();
|
|
278
|
+
}
|
|
279
|
+
// src/wrap-db-adapter.ts
|
|
280
|
+
function wrapDbAdapter(adapter, telemetry, dbSystem) {
|
|
281
|
+
if (!telemetry || !telemetry.active)
|
|
282
|
+
return adapter;
|
|
283
|
+
const wrapRead = (tx) => ({
|
|
284
|
+
find: async (store, options) => telemetry.startSpan(`db.find ${store}`, async (span) => {
|
|
285
|
+
const start = Date.now();
|
|
286
|
+
try {
|
|
287
|
+
const rows = await tx.find(store, options);
|
|
288
|
+
span.setAttribute("db.response.row_count", rows.length);
|
|
289
|
+
return rows;
|
|
290
|
+
} finally {
|
|
291
|
+
telemetry.measureSince("arc.db.find_ms", start, {
|
|
292
|
+
"db.system": dbSystem,
|
|
293
|
+
"db.collection.name": store
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}, {
|
|
297
|
+
kind: 3,
|
|
298
|
+
attributes: {
|
|
299
|
+
"db.system": dbSystem,
|
|
300
|
+
"db.operation.name": "find",
|
|
301
|
+
"db.collection.name": store
|
|
302
|
+
}
|
|
303
|
+
})
|
|
304
|
+
});
|
|
305
|
+
const wrapReadWrite = (tx) => ({
|
|
306
|
+
...wrapRead(tx),
|
|
307
|
+
set: async (store, data) => telemetry.startSpan(`db.set ${store}`, () => tx.set(store, data), {
|
|
308
|
+
kind: 3,
|
|
309
|
+
attributes: {
|
|
310
|
+
"db.system": dbSystem,
|
|
311
|
+
"db.operation.name": "set",
|
|
312
|
+
"db.collection.name": store
|
|
313
|
+
}
|
|
314
|
+
}),
|
|
315
|
+
remove: async (store, id) => telemetry.startSpan(`db.remove ${store}`, () => tx.remove(store, id), {
|
|
316
|
+
kind: 3,
|
|
317
|
+
attributes: {
|
|
318
|
+
"db.system": dbSystem,
|
|
319
|
+
"db.operation.name": "remove",
|
|
320
|
+
"db.collection.name": store
|
|
321
|
+
}
|
|
322
|
+
}),
|
|
323
|
+
commit: async () => telemetry.startSpan("db.commit", () => tx.commit(), {
|
|
324
|
+
kind: 3,
|
|
325
|
+
attributes: {
|
|
326
|
+
"db.system": dbSystem,
|
|
327
|
+
"db.operation.name": "commit"
|
|
328
|
+
}
|
|
329
|
+
})
|
|
330
|
+
});
|
|
331
|
+
return new Proxy(adapter, {
|
|
332
|
+
get(target, prop) {
|
|
333
|
+
const orig = target[prop];
|
|
334
|
+
if (prop === "readTransaction") {
|
|
335
|
+
return (...args) => wrapRead(orig.apply(target, args));
|
|
336
|
+
}
|
|
337
|
+
if (prop === "readWriteTransaction") {
|
|
338
|
+
return (...args) => wrapReadWrite(orig.apply(target, args));
|
|
339
|
+
}
|
|
340
|
+
return typeof orig === "function" ? orig.bind(target) : orig;
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// src/index.ts
|
|
346
|
+
import { SpanKind as SpanKind2, SpanStatusCode as SpanStatusCode2 } from "@opentelemetry/api";
|
|
347
|
+
export {
|
|
348
|
+
wrapDbAdapter,
|
|
349
|
+
sanitizeAttrs,
|
|
350
|
+
redactConnectionString,
|
|
351
|
+
injectTraceContext,
|
|
352
|
+
getActiveSpan,
|
|
353
|
+
extractTraceContext,
|
|
354
|
+
contextFromHeaders,
|
|
355
|
+
TRACE_CONTEXT_HEADERS,
|
|
356
|
+
SpanStatusCode2 as SpanStatusCode,
|
|
357
|
+
SpanKind2 as SpanKind,
|
|
358
|
+
DEFAULT_REDACT_KEY_PATTERN,
|
|
359
|
+
DEFAULT_MAX_STRING_LEN,
|
|
360
|
+
DEFAULT_MAX_JSON_LEN,
|
|
361
|
+
ArcTelemetry
|
|
362
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ArcTelemetry, type TelemetryConfig } from "./telemetry";
|
|
2
|
+
export interface BrowserInitResult {
|
|
3
|
+
telemetry: ArcTelemetry;
|
|
4
|
+
/** Flush pending spans (best-effort) when the page is about to unload. */
|
|
5
|
+
flush: () => Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export declare function initBrowserTelemetry(config: TelemetryConfig): BrowserInitResult;
|
|
8
|
+
//# sourceMappingURL=init-browser.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"init-browser.d.ts","sourceRoot":"","sources":["../src/init-browser.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,YAAY,EAAE,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAUjE,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,YAAY,CAAC;IACxB,0EAA0E;IAC1E,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5B;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,eAAe,GAAG,iBAAiB,CAgE/E"}
|