@refraction-ui/astro 0.5.1 → 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.
- package/dist/analytics/analytics-manager.ts +361 -0
- package/dist/analytics/consent.ts +40 -0
- package/dist/analytics/console-sink.ts +37 -0
- package/dist/analytics/http-sink.ts +213 -0
- package/dist/analytics/identity.ts +64 -0
- package/dist/analytics/index.ts +60 -0
- package/dist/analytics/mock-sink.ts +62 -0
- package/dist/analytics/noop.ts +48 -0
- package/dist/analytics/redaction.ts +93 -0
- package/dist/analytics/session.ts +162 -0
- package/dist/analytics/storage.ts +119 -0
- package/dist/analytics/types.ts +273 -0
- package/dist/analytics/uuid.ts +63 -0
- package/dist/astro-analytics/AnalyticsScript.astro +130 -0
- package/dist/astro-analytics/index.ts +49 -0
- package/dist/astro-logger/TelemetryScript.astro +108 -0
- package/dist/astro-logger/index.ts +48 -0
- package/dist/astro-logger/library-error-capture.ts +121 -0
- package/dist/astro-logger/telemetry-middleware.ts +92 -0
- package/dist/index.ts +2 -0
- package/dist/logger/console-sink.ts +64 -0
- package/dist/logger/faro-engine.ts +130 -0
- package/dist/logger/index.ts +39 -0
- package/dist/logger/mock-sink.ts +38 -0
- package/dist/logger/noop.ts +52 -0
- package/dist/logger/presets.ts +51 -0
- package/dist/logger/redact.ts +33 -0
- package/dist/logger/telemetry-manager.ts +229 -0
- package/dist/logger/types.ts +121 -0
- package/dist/shared/dev-feedback.ts +154 -0
- package/dist/shared/index.ts +25 -0
- package/dist/shared/library-origin-error.ts +240 -0
- 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
|
+
}
|
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
|
+
}
|