@refraction-ui/astro 0.5.0 → 0.6.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.
Files changed (38) hide show
  1. package/dist/analytics/analytics-manager.ts +361 -0
  2. package/dist/analytics/consent.ts +40 -0
  3. package/dist/analytics/console-sink.ts +37 -0
  4. package/dist/analytics/http-sink.ts +213 -0
  5. package/dist/analytics/identity.ts +64 -0
  6. package/dist/analytics/index.ts +60 -0
  7. package/dist/analytics/mock-sink.ts +62 -0
  8. package/dist/analytics/noop.ts +48 -0
  9. package/dist/analytics/redaction.ts +93 -0
  10. package/dist/analytics/session.ts +162 -0
  11. package/dist/analytics/storage.ts +119 -0
  12. package/dist/analytics/types.ts +273 -0
  13. package/dist/analytics/uuid.ts +63 -0
  14. package/dist/astro-analytics/AnalyticsScript.astro +130 -0
  15. package/dist/astro-analytics/index.ts +49 -0
  16. package/dist/astro-logger/TelemetryScript.astro +108 -0
  17. package/dist/astro-logger/index.ts +48 -0
  18. package/dist/astro-logger/library-error-capture.ts +121 -0
  19. package/dist/astro-logger/telemetry-middleware.ts +92 -0
  20. package/dist/astro-waveform/Waveform.astro +8 -2
  21. package/dist/index.ts +2 -0
  22. package/dist/logger/console-sink.ts +64 -0
  23. package/dist/logger/faro-engine.ts +130 -0
  24. package/dist/logger/index.ts +39 -0
  25. package/dist/logger/mock-sink.ts +38 -0
  26. package/dist/logger/noop.ts +52 -0
  27. package/dist/logger/presets.ts +51 -0
  28. package/dist/logger/redact.ts +33 -0
  29. package/dist/logger/telemetry-manager.ts +229 -0
  30. package/dist/logger/types.ts +121 -0
  31. package/dist/shared/dev-feedback.ts +154 -0
  32. package/dist/shared/index.ts +25 -0
  33. package/dist/shared/library-origin-error.ts +240 -0
  34. package/dist/voice-pill/voice-pill.styles.ts +14 -13
  35. package/dist/voice-pill/voice-pill.ts +3 -2
  36. package/dist/waveform/waveform-renderer.ts +13 -6
  37. package/dist/waveform/waveform.ts +15 -2
  38. package/package.json +4 -2
@@ -0,0 +1,121 @@
1
+ import {
2
+ captureLibraryOriginError,
3
+ type DevFeedbackRecord,
4
+ type DevFeedbackSink,
5
+ type LibraryOriginIdentity,
6
+ } from '../shared/index.ts'
7
+
8
+ /**
9
+ * library-error-capture — the Astro capture seam for epic #247 / issue #249.
10
+ *
11
+ * A `defineMiddleware`-compatible runtime hook that tags ONLY errors whose
12
+ * stack originates in a `@refraction-ui/*` package and routes them to an
13
+ * optional, consumer-wired telemetry sink. The filter → tag → route flow
14
+ * lives entirely in `@refraction-ui/shared`'s `captureLibraryOriginError`;
15
+ * this is the thin Astro middleware around it.
16
+ *
17
+ * The error is ALWAYS rethrown so Astro's normal error handling (and the
18
+ * app's own error pages) are untouched. App-origin errors are never tagged
19
+ * or forwarded to the diagnostics sink — they pass straight through.
20
+ *
21
+ * Zero hard dependency on the telemetry lib — the sink is the structural
22
+ * `DevFeedbackSink` from shared (the real `@refraction-ui/logger`
23
+ * `TelemetrySink` satisfies it). Nothing phones home unless a sink is wired.
24
+ */
25
+
26
+ /**
27
+ * Subset of Astro's middleware context we depend on. Kept structural so the
28
+ * adapter never needs Astro's types at build time (Astro is a peer dep and
29
+ * its middleware types vary across versions).
30
+ */
31
+ export interface LibraryErrorCaptureContext {
32
+ request: Request
33
+ locals: Record<string, unknown>
34
+ }
35
+
36
+ /** A `MiddlewareNext`-compatible continuation. */
37
+ export type LibraryErrorCaptureNext = () => Promise<Response> | Response
38
+
39
+ /** Options for {@link createLibraryErrorCapture}. */
40
+ export interface LibraryErrorCaptureOptions {
41
+ /**
42
+ * Fixed package/component identity tagged onto a captured library-origin
43
+ * error. Carries NO app data — the fingerprint is derived from the stack.
44
+ */
45
+ identity: LibraryOriginIdentity
46
+ /**
47
+ * Optional, consumer-wired telemetry sink. Library-origin errors are
48
+ * forwarded here when present; absent / `null` ⇒ no-op (nothing phones
49
+ * home). The real `@refraction-ui/logger` `TelemetrySink` satisfies this.
50
+ */
51
+ sink?: DevFeedbackSink | null
52
+ /**
53
+ * Invoked when a library-origin error is captured, with the tagged
54
+ * diagnostics record (after it has been forwarded to the sink). Optional;
55
+ * never called for app-origin errors.
56
+ */
57
+ onCapture?: (record: DevFeedbackRecord) => void
58
+ }
59
+
60
+ /**
61
+ * createLibraryErrorCapture — the Astro-idiomatic server-side capture hook.
62
+ *
63
+ * Returns a `defineMiddleware`-compatible `onRequest` handler that runs the
64
+ * downstream chain and, if it throws, captures the error iff it is
65
+ * `@refraction-ui/*`-origin (filter + tag + optional forward) and then
66
+ * ALWAYS rethrows so Astro's error handling is unchanged.
67
+ *
68
+ * ```ts
69
+ * // src/middleware.ts
70
+ * import { defineMiddleware } from 'astro:middleware'
71
+ * import { createLibraryErrorCapture } from '../astro-logger/index.ts'
72
+ *
73
+ * export const onRequest = defineMiddleware(
74
+ * createLibraryErrorCapture({
75
+ * identity: { package: '@refraction-ui/astro', componentName: 'app',
76
+ * version: '0.1.0', framework: 'astro' },
77
+ * sink: mySink, // omit ⇒ nothing phones home
78
+ * }),
79
+ * )
80
+ * ```
81
+ */
82
+ export function createLibraryErrorCapture(
83
+ options: LibraryErrorCaptureOptions,
84
+ ): (
85
+ context: LibraryErrorCaptureContext,
86
+ next: LibraryErrorCaptureNext,
87
+ ) => Promise<Response> {
88
+ const { identity, sink = null, onCapture } = options
89
+
90
+ return async function onRequest(
91
+ _context: LibraryErrorCaptureContext,
92
+ next: LibraryErrorCaptureNext,
93
+ ): Promise<Response> {
94
+ try {
95
+ return await next()
96
+ } catch (error) {
97
+ // Filter + tag + route happens entirely in shared. Returns the tagged
98
+ // record for a library-origin error, or null for an app-origin error
99
+ // (left completely untouched — not forwarded anywhere).
100
+ const record = captureLibraryOriginError(error, identity, sink)
101
+ if (record) onCapture?.(record)
102
+ // Always rethrow: Astro's normal error handling / error page is
103
+ // unchanged for both library- and app-origin errors.
104
+ throw error
105
+ }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * captureAstroLibraryError — the non-middleware Astro seam. Use from an
111
+ * endpoint / server island `try/catch` where the middleware cannot reach.
112
+ * Same contract: library-origin only, optional sink, app-origin returns
113
+ * `null` untouched. Never throws.
114
+ */
115
+ export function captureAstroLibraryError(
116
+ error: unknown,
117
+ identity: LibraryOriginIdentity,
118
+ sink?: DevFeedbackSink | null,
119
+ ): DevFeedbackRecord | null {
120
+ return captureLibraryOriginError(error, identity, sink)
121
+ }
@@ -0,0 +1,92 @@
1
+ import {
2
+ createTelemetry,
3
+ type Telemetry,
4
+ type TelemetryConfig,
5
+ } from '../logger/index.ts'
6
+
7
+ /**
8
+ * Subset of Astro's middleware context we depend on. Kept structural so the
9
+ * adapter never needs Astro's types at build time (Astro is a peer dep and
10
+ * its middleware types vary across versions).
11
+ */
12
+ export interface TelemetryMiddlewareContext {
13
+ request: Request
14
+ locals: Record<string, unknown>
15
+ }
16
+
17
+ /** A `MiddlewareNext`-compatible continuation. */
18
+ export type TelemetryMiddlewareNext = () => Promise<Response> | Response
19
+
20
+ /** Options for {@link createTelemetryMiddleware}. */
21
+ export interface TelemetryMiddlewareOptions extends TelemetryConfig {
22
+ /**
23
+ * Key under `context.locals` where the per-request logger is exposed.
24
+ * Defaults to `telemetry`.
25
+ */
26
+ localsKey?: string
27
+ /**
28
+ * Pre-built telemetry instance to reuse instead of constructing one from
29
+ * the config. Useful for sharing a single manager across requests.
30
+ */
31
+ telemetry?: Telemetry
32
+ }
33
+
34
+ /**
35
+ * createTelemetryMiddleware — the Astro-idiomatic server-side telemetry hook.
36
+ *
37
+ * Returns a `defineMiddleware`-compatible `onRequest` handler that:
38
+ * - builds (or reuses) a `@refraction-ui/logger` telemetry manager,
39
+ * - exposes a per-request child logger on `context.locals[localsKey]`
40
+ * (bound with method + path), so SSR pages/endpoints can log,
41
+ * - wraps the downstream chain in a span recording duration + status,
42
+ * - flushes buffered records once the response is produced.
43
+ *
44
+ * ```ts
45
+ * // src/middleware.ts
46
+ * import { defineMiddleware } from 'astro:middleware'
47
+ * import { createTelemetryMiddleware } from '../astro-logger/index.ts'
48
+ *
49
+ * export const onRequest = defineMiddleware(
50
+ * createTelemetryMiddleware({ app: 'web', env: 'production', endpoint: '/collect' }),
51
+ * )
52
+ * ```
53
+ */
54
+ export function createTelemetryMiddleware(
55
+ options: TelemetryMiddlewareOptions,
56
+ ): (
57
+ context: TelemetryMiddlewareContext,
58
+ next: TelemetryMiddlewareNext,
59
+ ) => Promise<Response> {
60
+ const { localsKey = 'telemetry', telemetry, ...config } = options
61
+ const root: Telemetry = telemetry ?? createTelemetry(config)
62
+
63
+ return async function onRequest(
64
+ context: TelemetryMiddlewareContext,
65
+ next: TelemetryMiddlewareNext,
66
+ ): Promise<Response> {
67
+ const url = new URL(context.request.url)
68
+ const method = context.request.method
69
+ const path = url.pathname
70
+
71
+ const requestLogger = root.child({ method, path })
72
+ context.locals[localsKey] = requestLogger
73
+
74
+ const span = requestLogger.startSpan('astro.request', { method, path })
75
+ try {
76
+ const response = await next()
77
+ span.end({ attributes: { status: response.status } })
78
+ return response
79
+ } catch (error) {
80
+ span.end({ error })
81
+ requestLogger.error('astro.request.error', {
82
+ method,
83
+ path,
84
+ message: error instanceof Error ? error.message : String(error),
85
+ })
86
+ throw error
87
+ } finally {
88
+ // Best-effort delivery of any buffered records for this request.
89
+ void root.flush()
90
+ }
91
+ }
92
+ }
@@ -12,6 +12,7 @@ import { cn } from '../shared/index.ts'
12
12
  interface Props extends Omit<astroHTML.JSX.HTMLAttributes, 'color' | 'height' | 'width'> {
13
13
  samples?: WaveformSampleInput
14
14
  intensity?: number
15
+ amplitude?: number
15
16
  variant?: WaveformVariant
16
17
  height?: number | string
17
18
  width?: number | string
@@ -24,6 +25,7 @@ interface Props extends Omit<astroHTML.JSX.HTMLAttributes, 'color' | 'height' |
24
25
  const {
25
26
  samples,
26
27
  intensity,
28
+ amplitude,
27
29
  variant,
28
30
  height,
29
31
  width,
@@ -39,6 +41,7 @@ const {
39
41
  const api = createWaveform({
40
42
  samples,
41
43
  intensity,
44
+ amplitude,
42
45
  variant,
43
46
  height,
44
47
  width,
@@ -65,6 +68,7 @@ const waveformData = {
65
68
  samples: Array.from(api.samples),
66
69
  variant: api.config.variant,
67
70
  intensity: api.config.intensity,
71
+ amplitude: api.config.amplitude,
68
72
  barCount: api.config.barCount,
69
73
  color: api.config.color,
70
74
  }
@@ -139,10 +143,11 @@ function toKebabCase(value: string): string {
139
143
  })
140
144
  }
141
145
 
142
- const scaleSamples = (samples, intensity) => {
146
+ const scaleSamples = (samples, intensity, amplitude) => {
143
147
  const amount = Math.max(0, Math.min(1, Number(intensity) || 0))
148
+ const visualHeight = Math.max(0, Math.min(1, Number(amplitude) || 0))
144
149
 
145
- return samples.map((sample) => Math.max(-1, Math.min(1, sample * amount)))
150
+ return samples.map((sample) => Math.max(-1, Math.min(1, sample * amount * visualHeight)))
146
151
  }
147
152
 
148
153
  const resolveColor = (element, color) => {
@@ -248,6 +253,7 @@ function toKebabCase(value: string): string {
248
253
  const samples = scaleSamples(
249
254
  normalizeSamples(config.samples, config.barCount),
250
255
  config.intensity,
256
+ config.amplitude,
251
257
  )
252
258
  const color = resolveColor(canvas.parentElement || canvas, config.color || 'currentColor')
253
259
 
package/dist/index.ts CHANGED
@@ -77,3 +77,5 @@ export * from './astro-link-card/index.ts';
77
77
  export * from './astro-card-grid/index.ts';
78
78
  export * from './astro-payment/index.ts';
79
79
  export * from './astro-command-input/index.ts';
80
+ export * from './astro-logger/index.ts';
81
+ export * from './astro-analytics/index.ts';
@@ -0,0 +1,64 @@
1
+ import type { LogLevel, LogRecord, SpanRecord, TelemetrySink } from './types.js'
2
+
3
+ /** Console method used for each level. */
4
+ const METHOD: Record<LogLevel, 'debug' | 'info' | 'warn' | 'error'> = {
5
+ debug: 'debug',
6
+ info: 'info',
7
+ warn: 'warn',
8
+ error: 'error',
9
+ fatal: 'error',
10
+ }
11
+
12
+ export interface ConsoleSinkOptions {
13
+ /** Single-line pretty output (vs. structured JSON). */
14
+ pretty?: boolean
15
+ /** Console to write to (injectable for tests). Defaults to global console. */
16
+ console?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>
17
+ }
18
+
19
+ /**
20
+ * Default zero-dependency transport. Used whenever no `endpoint` is set.
21
+ * Synchronous; `flush()` is a resolved no-op (nothing is buffered).
22
+ */
23
+ export function createConsoleSink(opts?: ConsoleSinkOptions): TelemetrySink {
24
+ const pretty = opts?.pretty ?? true
25
+ const out = opts?.console ?? console
26
+
27
+ function emit(level: LogLevel, line: string, payload: unknown): void {
28
+ out[METHOD[level]](line, payload)
29
+ }
30
+
31
+ return {
32
+ name: 'console',
33
+
34
+ log(record: LogRecord): void {
35
+ if (pretty) {
36
+ const ts = new Date(record.timestamp).toISOString()
37
+ emit(
38
+ record.level,
39
+ `${ts} ${record.level.toUpperCase()} [${record.app}] ${record.message}`,
40
+ record.context,
41
+ )
42
+ } else {
43
+ emit(record.level, JSON.stringify({ type: 'log', ...record }), record.context)
44
+ }
45
+ },
46
+
47
+ span(record: SpanRecord): void {
48
+ const level: LogLevel = record.status === 'error' ? 'error' : 'debug'
49
+ if (pretty) {
50
+ emit(
51
+ level,
52
+ `[span] ${record.name} ${record.durationMs.toFixed(2)}ms (${record.status})`,
53
+ record.context,
54
+ )
55
+ } else {
56
+ emit(level, JSON.stringify({ type: 'span', ...record }), record.context)
57
+ }
58
+ },
59
+
60
+ async flush(): Promise<void> {
61
+ // Console is synchronous — nothing buffered.
62
+ },
63
+ }
64
+ }
@@ -0,0 +1,130 @@
1
+ import type { LogLevel, LogRecord, SpanRecord, TelemetrySink } from './types.js'
2
+
3
+ /**
4
+ * Faro-backed engine. `@grafana/faro-web-sdk` + `@grafana/faro-web-tracing`
5
+ * are **optional peerDependencies** — they are loaded dynamically and never
6
+ * referenced in this module's public types. If the peers are absent the
7
+ * factory resolves to `null` so the caller can fall back to console.
8
+ *
9
+ * For tests, a `transport` may be injected: an object with a `push(payload)`
10
+ * method. This bypasses Faro entirely (no network, no peer required).
11
+ */
12
+
13
+ /** Minimal structural shape of a Faro-ish transport. Not exported. */
14
+ interface FaroTransport {
15
+ push(payload: { kind: 'log' | 'span'; record: LogRecord | SpanRecord }): void
16
+ }
17
+
18
+ export interface FaroEngineOptions {
19
+ app: string
20
+ endpoint: string
21
+ /**
22
+ * Test/override transport. When provided, the Faro peers are NOT loaded
23
+ * and records are forwarded straight to `transport.push`.
24
+ */
25
+ transport?: FaroTransport
26
+ }
27
+
28
+ /** Map our levels onto Faro's log-level strings. */
29
+ const FARO_LEVEL: Record<LogLevel, string> = {
30
+ debug: 'debug',
31
+ info: 'info',
32
+ warn: 'warn',
33
+ error: 'error',
34
+ fatal: 'error',
35
+ }
36
+
37
+ /**
38
+ * Construct the Faro engine. Returns `null` when the optional peers are not
39
+ * installed and no override transport was supplied — callers treat `null` as
40
+ * "fall back to console".
41
+ */
42
+ export async function createFaroSink(
43
+ opts: FaroEngineOptions,
44
+ ): Promise<TelemetrySink | null> {
45
+ const transport = opts.transport ?? (await loadFaroTransport(opts))
46
+ if (!transport) return null
47
+
48
+ return {
49
+ name: 'faro',
50
+
51
+ log(record: LogRecord): void {
52
+ transport.push({ kind: 'log', record })
53
+ },
54
+
55
+ span(record: SpanRecord): void {
56
+ transport.push({ kind: 'span', record })
57
+ },
58
+
59
+ async flush(): Promise<void> {
60
+ // Faro's own transports flush on their schedule / on beacon; the
61
+ // manager drives page-exit beacon flushing. Nothing buffered here.
62
+ },
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Dynamically import the Faro peers and adapt them to {@link FaroTransport}.
68
+ * Returns `null` if either peer is missing (optional peerDependency absent).
69
+ */
70
+ async function loadFaroTransport(
71
+ opts: FaroEngineOptions,
72
+ ): Promise<FaroTransport | null> {
73
+ try {
74
+ // Indirected so bundlers keep these as runtime-optional dynamic imports.
75
+ const sdkName = '@grafana/faro-web-sdk'
76
+ const tracingName = '@grafana/faro-web-tracing'
77
+ const sdk = (await import(/* @vite-ignore */ sdkName)) as {
78
+ initializeFaro: (cfg: unknown) => unknown
79
+ getWebInstrumentations: () => unknown[]
80
+ }
81
+ const tracing = (await import(/* @vite-ignore */ tracingName)) as {
82
+ TracingInstrumentation: new () => unknown
83
+ }
84
+
85
+ const faro = sdk.initializeFaro({
86
+ url: opts.endpoint,
87
+ app: { name: opts.app },
88
+ instrumentations: [
89
+ ...sdk.getWebInstrumentations(),
90
+ new tracing.TracingInstrumentation(),
91
+ ],
92
+ }) as {
93
+ api: {
94
+ pushLog: (msgs: unknown[], opts?: unknown) => void
95
+ pushEvent: (name: string, attrs?: Record<string, unknown>) => void
96
+ }
97
+ }
98
+
99
+ return {
100
+ push({ kind, record }): void {
101
+ if (kind === 'log') {
102
+ const r = record as LogRecord
103
+ faro.api.pushLog([r.message], {
104
+ level: FARO_LEVEL[r.level],
105
+ context: flatten(r.context),
106
+ })
107
+ } else {
108
+ const r = record as SpanRecord
109
+ faro.api.pushEvent(`span:${r.name}`, {
110
+ durationMs: String(r.durationMs),
111
+ status: r.status,
112
+ ...flatten(r.context),
113
+ })
114
+ }
115
+ },
116
+ }
117
+ } catch {
118
+ // Peer not installed (optional) or init failed — caller falls back.
119
+ return null
120
+ }
121
+ }
122
+
123
+ /** Faro context attributes are flat string maps; coerce ours to match. */
124
+ function flatten(ctx: Record<string, unknown>): Record<string, string> {
125
+ const out: Record<string, string> = {}
126
+ for (const [k, v] of Object.entries(ctx)) {
127
+ out[k] = typeof v === 'string' ? v : JSON.stringify(v)
128
+ }
129
+ return out
130
+ }
@@ -0,0 +1,39 @@
1
+ // Types (vendor-neutral — no Faro/Grafana types are exported)
2
+ export type {
3
+ LogLevel,
4
+ LogContext,
5
+ LogRecord,
6
+ SpanRecord,
7
+ TelemetrySink,
8
+ TelemetryEnv,
9
+ TelemetryConfig,
10
+ Logger,
11
+ Span,
12
+ Telemetry,
13
+ } from './types.js'
14
+ export { LEVEL_ORDER } from './types.js'
15
+
16
+ // Manager
17
+ export { createTelemetry } from './telemetry-manager.js'
18
+ export type { TelemetryPreset } from './telemetry-manager.js'
19
+
20
+ // Presets
21
+ export { PRESETS, resolvePreset } from './presets.js'
22
+
23
+ // Built-in transports
24
+ export { createConsoleSink } from './console-sink.js'
25
+ export type { ConsoleSinkOptions } from './console-sink.js'
26
+
27
+ // Faro engine (optional peers loaded dynamically; types stay vendor-neutral)
28
+ export { createFaroSink } from './faro-engine.js'
29
+ export type { FaroEngineOptions } from './faro-engine.js'
30
+
31
+ // Kill switch
32
+ export { createNoopTelemetry } from './noop.js'
33
+
34
+ // Utilities
35
+ export { redact } from './redact.js'
36
+
37
+ // Mock sink (for testing)
38
+ export { createMockSink } from './mock-sink.js'
39
+ export type { MockSinkExtended } from './mock-sink.js'
@@ -0,0 +1,38 @@
1
+ import type { LogRecord, SpanRecord, TelemetrySink } from './types.js'
2
+
3
+ export interface MockSinkExtended extends TelemetrySink {
4
+ /** Every log record received, in order. */
5
+ logs: LogRecord[]
6
+ /** Every span record received, in order. */
7
+ spans: SpanRecord[]
8
+ /** Number of times {@link TelemetrySink.flush} was called. */
9
+ flushCalls: number
10
+ }
11
+
12
+ /**
13
+ * createMockSink — a {@link TelemetrySink} that records everything for
14
+ * assertions instead of doing I/O. Used to test the manager and the Faro
15
+ * engine without any network. Mirrors `createMockAIProvider` in `packages/ai`.
16
+ */
17
+ export function createMockSink(name: string = 'mock'): MockSinkExtended {
18
+ const sink: MockSinkExtended = {
19
+ name,
20
+ logs: [],
21
+ spans: [],
22
+ flushCalls: 0,
23
+
24
+ log(record: LogRecord): void {
25
+ sink.logs.push(record)
26
+ },
27
+
28
+ span(record: SpanRecord): void {
29
+ sink.spans.push(record)
30
+ },
31
+
32
+ async flush(): Promise<void> {
33
+ sink.flushCalls++
34
+ },
35
+ }
36
+
37
+ return sink
38
+ }
@@ -0,0 +1,52 @@
1
+ import type { Span, Telemetry } from './types.js'
2
+
3
+ /** Shared no-op span — emits nothing, allocates nothing per call. */
4
+ const NOOP_SPAN: Span = {
5
+ end(): void {
6
+ /* no-op */
7
+ },
8
+ }
9
+
10
+ /**
11
+ * Returned by {@link createTelemetry} when `enabled: false`. Every method is
12
+ * an empty stub, so a bundler can dead-code-eliminate call sites and the
13
+ * engines (console/Faro) are never imported at runtime. Zero emissions.
14
+ */
15
+ export function createNoopTelemetry(): Telemetry {
16
+ const noop: Telemetry = {
17
+ debug(): void {
18
+ /* no-op */
19
+ },
20
+ info(): void {
21
+ /* no-op */
22
+ },
23
+ warn(): void {
24
+ /* no-op */
25
+ },
26
+ error(): void {
27
+ /* no-op */
28
+ },
29
+ fatal(): void {
30
+ /* no-op */
31
+ },
32
+ child(): Telemetry {
33
+ return noop
34
+ },
35
+ startSpan(): Span {
36
+ return NOOP_SPAN
37
+ },
38
+ async flush(): Promise<void> {
39
+ /* no-op */
40
+ },
41
+ get sinks(): string[] {
42
+ return []
43
+ },
44
+ addSink(): void {
45
+ /* no-op */
46
+ },
47
+ removeSink(): void {
48
+ /* no-op */
49
+ },
50
+ }
51
+ return noop
52
+ }
@@ -0,0 +1,51 @@
1
+ import type { LogLevel, TelemetryEnv } from './types.js'
2
+
3
+ /**
4
+ * Behavior derived from {@link TelemetryConfig.env}. The manager reads these
5
+ * fields to decide level filtering, batching, sampling, and flush triggers.
6
+ */
7
+ export interface TelemetryPreset {
8
+ /** Records strictly below this level are dropped. */
9
+ minLevel: LogLevel
10
+ /** Buffer records and deliver in batches instead of synchronously. */
11
+ batch: boolean
12
+ /** Batch size before an automatic flush (only when `batch`). */
13
+ batchSize: number
14
+ /** Default sample rate when config omits `sampleRate`. */
15
+ sampleRate: number
16
+ /** Pretty, single-line console output (vs. structured JSON). */
17
+ pretty: boolean
18
+ /**
19
+ * Flush buffered records on `pagehide` + `visibilitychange` (hidden),
20
+ * using `navigator.sendBeacon` when available.
21
+ */
22
+ beaconFlush: boolean
23
+ }
24
+
25
+ /**
26
+ * - development: sync, pretty, level=debug, no batching, sample everything.
27
+ * - production: batched + sampled, level>=warn, beacon flush on page exit.
28
+ */
29
+ export const PRESETS: Record<TelemetryEnv, TelemetryPreset> = {
30
+ development: {
31
+ minLevel: 'debug',
32
+ batch: false,
33
+ batchSize: 1,
34
+ sampleRate: 1,
35
+ pretty: true,
36
+ beaconFlush: false,
37
+ },
38
+ production: {
39
+ minLevel: 'warn',
40
+ batch: true,
41
+ batchSize: 20,
42
+ sampleRate: 0.25,
43
+ pretty: false,
44
+ beaconFlush: true,
45
+ },
46
+ }
47
+
48
+ /** Resolve the preset for an env (defensive copy so callers can't mutate it). */
49
+ export function resolvePreset(env: TelemetryEnv): TelemetryPreset {
50
+ return { ...PRESETS[env] }
51
+ }
@@ -0,0 +1,33 @@
1
+ import type { LogContext } from './types.js'
2
+
3
+ /**
4
+ * Deep-strip any object key whose name (case-insensitive) is in `keys`.
5
+ * Arrays are walked; cycles are guarded; non-matching values pass through
6
+ * unchanged. Returns a new structure — the input is never mutated.
7
+ */
8
+ export function redact(value: LogContext, keys: string[]): LogContext {
9
+ if (keys.length === 0) return value
10
+ const lookup = new Set(keys.map((k) => k.toLowerCase()))
11
+ return walk(value, lookup, new WeakSet()) as LogContext
12
+ }
13
+
14
+ function walk(value: unknown, keys: Set<string>, seen: WeakSet<object>): unknown {
15
+ if (value === null || typeof value !== 'object') return value
16
+
17
+ if (seen.has(value as object)) return '[Circular]'
18
+ seen.add(value as object)
19
+
20
+ if (Array.isArray(value)) {
21
+ return value.map((item) => walk(item, keys, seen))
22
+ }
23
+
24
+ const out: Record<string, unknown> = {}
25
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
26
+ if (keys.has(k.toLowerCase())) {
27
+ out[k] = '[REDACTED]'
28
+ continue
29
+ }
30
+ out[k] = walk(v, keys, seen)
31
+ }
32
+ return out
33
+ }