@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.
@@ -0,0 +1,229 @@
1
+ import { createConsoleSink } from './console-sink.js'
2
+ import { createNoopTelemetry } from './noop.js'
3
+ import { resolvePreset, type TelemetryPreset } from './presets.js'
4
+ import { redact } from './redact.js'
5
+ import {
6
+ LEVEL_ORDER,
7
+ type LogContext,
8
+ type LogLevel,
9
+ type LogRecord,
10
+ type Span,
11
+ type SpanRecord,
12
+ type Telemetry,
13
+ type TelemetryConfig,
14
+ type TelemetrySink,
15
+ } from './types.js'
16
+
17
+ /**
18
+ * createTelemetry — creates a telemetry manager that fans records out to
19
+ * registered sinks. Manager/provider pattern, mirroring `createAI`.
20
+ *
21
+ * - `enabled: false` -> tree-shakeable noop (zero emissions, no engines).
22
+ * - no `endpoint` -> console-only transport.
23
+ * - `endpoint` set -> async Faro engine is registered when the optional
24
+ * peers exist; console stays as a safe fallback until then.
25
+ *
26
+ * The returned object IS a logger (root context = `{}`); `child()` derives
27
+ * loggers with bound context (sessionId / interviewId / turnId / ...).
28
+ */
29
+ export function createTelemetry(config: TelemetryConfig): Telemetry {
30
+ if (config.enabled === false) {
31
+ return createNoopTelemetry()
32
+ }
33
+
34
+ const preset = resolvePreset(config.env)
35
+ const sampleRate = config.sampleRate ?? preset.sampleRate
36
+ const redactKeys = config.redactKeys ?? []
37
+
38
+ const sinks = new Map<string, TelemetrySink>()
39
+ /** Insertion-ordered sink names. */
40
+ const sinkOrder: string[] = []
41
+ /** Batch buffer (production preset only). */
42
+ const buffer: Array<{ kind: 'log'; record: LogRecord } | { kind: 'span'; record: SpanRecord }> = []
43
+
44
+ function addSinkInternal(sink: TelemetrySink): void {
45
+ if (sinks.has(sink.name)) {
46
+ sinks.set(sink.name, sink)
47
+ } else {
48
+ sinks.set(sink.name, sink)
49
+ sinkOrder.push(sink.name)
50
+ }
51
+ }
52
+
53
+ // Console transport is always present as the zero-dependency baseline.
54
+ addSinkInternal(createConsoleSink({ pretty: preset.pretty }))
55
+
56
+ // When an endpoint is configured, attempt to attach the Faro engine. The
57
+ // import is lazy + dynamic so the optional peers never become required and
58
+ // Faro names stay out of the public API.
59
+ if (config.endpoint) {
60
+ const endpoint = config.endpoint
61
+ void import('./faro-engine.js')
62
+ .then(({ createFaroSink }) => createFaroSink({ app: config.app, endpoint }))
63
+ .then((faro) => {
64
+ if (faro) addSinkInternal(faro)
65
+ })
66
+ .catch(() => {
67
+ /* peers absent or init failed — console remains */
68
+ })
69
+ }
70
+
71
+ function shouldSample(): boolean {
72
+ if (sampleRate >= 1) return true
73
+ if (sampleRate <= 0) return false
74
+ return Math.random() < sampleRate
75
+ }
76
+
77
+ function dispatch(
78
+ entry: { kind: 'log'; record: LogRecord } | { kind: 'span'; record: SpanRecord },
79
+ ): void {
80
+ if (preset.batch) {
81
+ buffer.push(entry)
82
+ if (buffer.length >= preset.batchSize) {
83
+ void flushBuffer()
84
+ }
85
+ return
86
+ }
87
+ deliver(entry)
88
+ }
89
+
90
+ function deliver(
91
+ entry: { kind: 'log'; record: LogRecord } | { kind: 'span'; record: SpanRecord },
92
+ ): void {
93
+ for (const name of sinkOrder) {
94
+ const sink = sinks.get(name)
95
+ if (!sink) continue
96
+ if (entry.kind === 'log') sink.log(entry.record)
97
+ else sink.span(entry.record)
98
+ }
99
+ }
100
+
101
+ async function flushBuffer(): Promise<void> {
102
+ if (buffer.length > 0) {
103
+ const pending = buffer.splice(0, buffer.length)
104
+ for (const entry of pending) deliver(entry)
105
+ }
106
+ await Promise.all(
107
+ sinkOrder.map((name) => sinks.get(name)?.flush() ?? Promise.resolve()),
108
+ )
109
+ }
110
+
111
+ // ---- page-exit beacon flush (production preset) -------------------------
112
+ // Browser only — `navigator.sendBeacon` is used by the underlying engine
113
+ // transport; here we just trigger a flush on the page-exit signals.
114
+ const root = globalThis as {
115
+ addEventListener?: (type: string, listener: () => void) => void
116
+ document?: { visibilityState?: string }
117
+ navigator?: { sendBeacon?: unknown }
118
+ }
119
+ if (preset.beaconFlush && typeof root.addEventListener === 'function') {
120
+ const onExit = (): void => {
121
+ void flushBuffer()
122
+ }
123
+ root.addEventListener('pagehide', onExit)
124
+ root.addEventListener('visibilitychange', () => {
125
+ if (root.document?.visibilityState === 'hidden') onExit()
126
+ })
127
+ }
128
+
129
+ function makeLogger(boundContext: LogContext): Telemetry {
130
+ function emit(level: LogLevel, message: string, context?: LogContext): void {
131
+ if (LEVEL_ORDER[level] < LEVEL_ORDER[preset.minLevel]) return
132
+ if (!shouldSample()) return
133
+ const merged = redact({ ...boundContext, ...context }, redactKeys)
134
+ const record: LogRecord = {
135
+ level,
136
+ message,
137
+ timestamp: Date.now(),
138
+ app: config.app,
139
+ env: config.env,
140
+ context: merged,
141
+ }
142
+ dispatch({ kind: 'log', record })
143
+ }
144
+
145
+ const logger: Telemetry = {
146
+ debug(message, context?) {
147
+ emit('debug', message, context)
148
+ },
149
+ info(message, context?) {
150
+ emit('info', message, context)
151
+ },
152
+ warn(message, context?) {
153
+ emit('warn', message, context)
154
+ },
155
+ error(message, context?) {
156
+ emit('error', message, context)
157
+ },
158
+ fatal(message, context?) {
159
+ emit('fatal', message, context)
160
+ },
161
+
162
+ child(context: LogContext): Telemetry {
163
+ return makeLogger({ ...boundContext, ...context })
164
+ },
165
+
166
+ startSpan(name: string, attributes?: LogContext): Span {
167
+ const startTime = Date.now()
168
+ let ended = false
169
+ return {
170
+ end(opts?: { error?: unknown; attributes?: LogContext }): void {
171
+ if (ended) return
172
+ ended = true
173
+ const endTime = Date.now()
174
+ const merged = redact(
175
+ { ...boundContext, ...attributes, ...opts?.attributes },
176
+ redactKeys,
177
+ )
178
+ const err = opts?.error
179
+ const record: SpanRecord = {
180
+ name,
181
+ startTime,
182
+ endTime,
183
+ durationMs: endTime - startTime,
184
+ app: config.app,
185
+ env: config.env,
186
+ context: merged,
187
+ status: err ? 'error' : 'ok',
188
+ ...(err
189
+ ? {
190
+ error: {
191
+ name: err instanceof Error ? err.name : 'Error',
192
+ message: err instanceof Error ? err.message : String(err),
193
+ },
194
+ }
195
+ : {}),
196
+ }
197
+ dispatch({ kind: 'span', record })
198
+ },
199
+ }
200
+ },
201
+
202
+ flush(): Promise<void> {
203
+ return flushBuffer()
204
+ },
205
+
206
+ get sinks(): string[] {
207
+ return [...sinkOrder]
208
+ },
209
+
210
+ addSink(sink: TelemetrySink): void {
211
+ addSinkInternal(sink)
212
+ },
213
+
214
+ removeSink(name: string): void {
215
+ if (sinks.has(name)) {
216
+ sinks.delete(name)
217
+ const idx = sinkOrder.indexOf(name)
218
+ if (idx !== -1) sinkOrder.splice(idx, 1)
219
+ }
220
+ },
221
+ }
222
+
223
+ return logger
224
+ }
225
+
226
+ return makeLogger({})
227
+ }
228
+
229
+ export type { TelemetryPreset }
@@ -0,0 +1,121 @@
1
+ /** Severity levels, ordered low -> high. */
2
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'
3
+
4
+ /** Numeric ordering used for level threshold comparisons. */
5
+ export const LEVEL_ORDER: Record<LogLevel, number> = {
6
+ debug: 10,
7
+ info: 20,
8
+ warn: 30,
9
+ error: 40,
10
+ fatal: 50,
11
+ }
12
+
13
+ /** Bound key/value pairs attached to every record emitted by a logger. */
14
+ export type LogContext = Record<string, unknown>
15
+
16
+ /** A single structured log record handed to a sink. */
17
+ export interface LogRecord {
18
+ level: LogLevel
19
+ message: string
20
+ timestamp: number
21
+ app: string
22
+ env: TelemetryEnv
23
+ /** Merged bound context (child loggers) + per-call context, post-redaction. */
24
+ context: LogContext
25
+ }
26
+
27
+ /** A span record handed to a sink when a span ends. */
28
+ export interface SpanRecord {
29
+ name: string
30
+ startTime: number
31
+ endTime: number
32
+ durationMs: number
33
+ app: string
34
+ env: TelemetryEnv
35
+ /** Merged bound context + span attributes, post-redaction. */
36
+ context: LogContext
37
+ /** 'ok' unless ended with an error. */
38
+ status: 'ok' | 'error'
39
+ error?: { name: string; message: string }
40
+ }
41
+
42
+ /**
43
+ * Vendor-neutral telemetry sink. Engines (console, Faro, custom) implement
44
+ * this — no vendor type ever leaks across this boundary.
45
+ */
46
+ export interface TelemetrySink {
47
+ /** Engine name, for diagnostics. */
48
+ name: string
49
+ /** Receive a log record. May buffer; honor flush(). */
50
+ log(record: LogRecord): void
51
+ /** Receive a finished span. May buffer; honor flush(). */
52
+ span(record: SpanRecord): void
53
+ /** Force-deliver any buffered records. Resolves once delivery is attempted. */
54
+ flush(): Promise<void>
55
+ }
56
+
57
+ /** Telemetry environment — selects a behavior preset. */
58
+ export type TelemetryEnv = 'development' | 'production'
59
+
60
+ /** Config for {@link createTelemetry} — mirrors `createAI`'s config shape. */
61
+ export interface TelemetryConfig {
62
+ /** Logical app/service name attached to every record. */
63
+ app: string
64
+ /** Environment preset selector. */
65
+ env: TelemetryEnv
66
+ /**
67
+ * Remote collector endpoint. When omitted, telemetry stays console-only
68
+ * (no network engine is constructed).
69
+ */
70
+ endpoint?: string
71
+ /**
72
+ * Master kill switch. When `false`, a tree-shakeable noop logger is
73
+ * returned and zero records are ever produced. Defaults to `true`.
74
+ */
75
+ enabled?: boolean
76
+ /**
77
+ * Fraction of records kept, 0..1. Defaults to the preset value
78
+ * (1 in development, 0.25 in production). Records below the sample
79
+ * are dropped before reaching any sink.
80
+ */
81
+ sampleRate?: number
82
+ /**
83
+ * Context keys to strip (deep) before a record is emitted. Use for
84
+ * PII / secrets, e.g. `['password', 'token', 'authorization']`.
85
+ */
86
+ redactKeys?: string[]
87
+ }
88
+
89
+ /** A logger bound to a context. Child loggers inherit + extend that context. */
90
+ export interface Logger {
91
+ debug(message: string, context?: LogContext): void
92
+ info(message: string, context?: LogContext): void
93
+ warn(message: string, context?: LogContext): void
94
+ error(message: string, context?: LogContext): void
95
+ fatal(message: string, context?: LogContext): void
96
+ /**
97
+ * Derive a logger with additional bound context (e.g.
98
+ * `{ sessionId, interviewId, turnId }`). Merges over the parent's context.
99
+ */
100
+ child(context: LogContext): Logger
101
+ /** Begin a span. Call {@link Span.end} to record its duration. */
102
+ startSpan(name: string, attributes?: LogContext): Span
103
+ /** Force-deliver buffered records on every sink. */
104
+ flush(): Promise<void>
105
+ }
106
+
107
+ /** An in-flight span returned by {@link Logger.startSpan}. */
108
+ export interface Span {
109
+ /** End the span and emit a {@link SpanRecord}. Optionally attach error/attrs. */
110
+ end(opts?: { error?: unknown; attributes?: LogContext }): void
111
+ }
112
+
113
+ /** Return type of {@link createTelemetry}. */
114
+ export interface Telemetry extends Logger {
115
+ /** Registered sink names, in insertion order. */
116
+ readonly sinks: string[]
117
+ /** Register an additional sink (e.g. a custom collector). */
118
+ addSink(sink: TelemetrySink): void
119
+ /** Remove a sink by name. */
120
+ removeSink(name: string): void
121
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * dev-feedback — zero-dependency `devWarn` / `devError` primitives.
3
+ *
4
+ * Design constraints (epic #247, issue #248):
5
+ * - Guarded by `process.env.NODE_ENV !== 'production'` so production bundlers
6
+ * dead-code-strip every call (the guard is a static string compare that
7
+ * minifiers fold to `false` in prod builds).
8
+ * - Warn-once dedupe per `code` — a footgun is reported once, not on every
9
+ * render.
10
+ * - NO import of `@refraction-ui/logger` (no hard dependency on the telemetry
11
+ * lib). Forwarding to a telemetry sink happens ONLY if the consumer
12
+ * explicitly injects one (dependency inversion, never an import).
13
+ */
14
+
15
+ /**
16
+ * Minimal structural shape of the record a telemetry sink consumes. This is a
17
+ * deliberate structural mirror of `@refraction-ui/logger`'s `LogRecord` — it is
18
+ * NOT imported, so `@refraction-ui/shared` keeps zero dependency on the
19
+ * telemetry lib. A consumer that wires the real logger sink satisfies this
20
+ * shape structurally.
21
+ */
22
+ export interface DevFeedbackRecord {
23
+ level: 'warn' | 'error'
24
+ message: string
25
+ timestamp: number
26
+ /** Structured detail — the library-origin envelope lives here. */
27
+ context: Record<string, unknown>
28
+ }
29
+
30
+ /**
31
+ * The narrow contract a consumer-injected telemetry sink must satisfy. Kept
32
+ * intentionally minimal and structural so the real `TelemetrySink` from
33
+ * `@refraction-ui/logger` is assignable WITHOUT shared importing the logger.
34
+ */
35
+ export interface DevFeedbackSink {
36
+ /** Receive a single dev-feedback record. Must never throw to the caller. */
37
+ log(record: DevFeedbackRecord): void
38
+ }
39
+
40
+ /**
41
+ * Minimal ambient view of `process.env` so we can read `NODE_ENV` WITHOUT
42
+ * pulling `@types/node` into this zero-dependency package. Accessed defensively
43
+ * (the `typeof process === 'undefined'` guard) so this is safe in browsers too.
44
+ */
45
+ declare const process:
46
+ | { env?: { NODE_ENV?: string } }
47
+ | undefined
48
+
49
+ /** Per-code dedupe set — module-scoped so it survives across calls. */
50
+ const seen = new Set<string>()
51
+
52
+ /**
53
+ * Optional, consumer-injected sink. `null` until a consumer explicitly wires
54
+ * one via {@link setDevFeedbackSink}. Nothing phones home implicitly.
55
+ */
56
+ let injectedSink: DevFeedbackSink | null = null
57
+
58
+ /**
59
+ * Wire an optional telemetry sink that {@link devWarn} / {@link devError}
60
+ * forward to (in addition to the console). Inversion of control: the consumer
61
+ * owns the sink; this package never imports it. Pass `null` to unwire.
62
+ *
63
+ * Forwarding still only happens in non-production (the calls themselves are
64
+ * stripped in prod), and still respects warn-once dedupe.
65
+ */
66
+ export function setDevFeedbackSink(sink: DevFeedbackSink | null): void {
67
+ injectedSink = sink
68
+ }
69
+
70
+ /** Test-only / consumer-only escape hatch to reset warn-once dedupe state. */
71
+ export function resetDevFeedback(): void {
72
+ seen.clear()
73
+ }
74
+
75
+ function isDev(): boolean {
76
+ // String compare (not a negated truthiness) so bundlers can statically fold
77
+ // `process.env.NODE_ENV` and strip the whole branch in production builds.
78
+ return (
79
+ typeof process === 'undefined' ||
80
+ process.env?.NODE_ENV !== 'production'
81
+ )
82
+ }
83
+
84
+ function emit(
85
+ level: 'warn' | 'error',
86
+ code: string,
87
+ message: string,
88
+ detail?: Record<string, unknown>,
89
+ ): void {
90
+ if (!isDev()) return
91
+
92
+ // Warn-once dedupe, keyed by (level, code) so an error and a warning sharing
93
+ // a code are not collapsed into one.
94
+ const key = `${level}:${code}`
95
+ if (seen.has(key)) return
96
+ seen.add(key)
97
+
98
+ const text = `[refraction-ui] ${code}: ${message}`
99
+
100
+ if (level === 'error') {
101
+ console.error(text, detail ?? '')
102
+ } else {
103
+ console.warn(text, detail ?? '')
104
+ }
105
+
106
+ // Forward to the consumer-injected sink ONLY if one was explicitly wired.
107
+ if (injectedSink) {
108
+ const record: DevFeedbackRecord = {
109
+ level,
110
+ message: `${code}: ${message}`,
111
+ timestamp: Date.now(),
112
+ context: { code, ...(detail ?? {}) },
113
+ }
114
+ try {
115
+ injectedSink.log(record)
116
+ } catch {
117
+ // A broken sink must never break the consumer app.
118
+ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Emit a development-only warning for a refraction-ui footgun.
124
+ *
125
+ * @param code Stable, greppable identifier (e.g. `'react/no-controlled-prop'`).
126
+ * @param message Human-readable explanation.
127
+ * @param detail Optional structured detail (forwarded to an injected sink).
128
+ *
129
+ * Stripped entirely in production. Warned at most once per `code`.
130
+ */
131
+ export function devWarn(
132
+ code: string,
133
+ message: string,
134
+ detail?: Record<string, unknown>,
135
+ ): void {
136
+ emit('warn', code, message, detail)
137
+ }
138
+
139
+ /**
140
+ * Emit a development-only error for a refraction-ui misuse / invariant break.
141
+ *
142
+ * @param code Stable, greppable identifier.
143
+ * @param message Human-readable explanation.
144
+ * @param detail Optional structured detail (forwarded to an injected sink).
145
+ *
146
+ * Stripped entirely in production. Reported at most once per `code`.
147
+ */
148
+ export function devError(
149
+ code: string,
150
+ message: string,
151
+ detail?: Record<string, unknown>,
152
+ ): void {
153
+ emit('error', code, message, detail)
154
+ }
@@ -48,6 +48,18 @@ export type {
48
48
  SkipLinkProps,
49
49
  } from './skip-link.js'
50
50
 
51
+ export type {
52
+ DevFeedbackRecord,
53
+ DevFeedbackSink,
54
+ } from './dev-feedback.js'
55
+
56
+ export type {
57
+ LibraryFramework,
58
+ LibraryOriginErrorInput,
59
+ LibraryOriginEnvelope,
60
+ LibraryOriginIdentity,
61
+ } from './library-origin-error.js'
62
+
51
63
  // Functions
52
64
  export { mergeAriaProps, generateId, resetIdCounter } from './aria.js'
53
65
  export { Keys, createKeyboardHandler } from './keyboard.js'
@@ -58,3 +70,16 @@ export { FOCUSABLE_SELECTOR, getFocusableElements, createFocusTrap } from './foc
58
70
  export { createLiveRegion } from './live-region.js'
59
71
  export { prefersReducedMotion, getAnimationDuration } from './motion.js'
60
72
  export { createSkipLink } from './skip-link.js'
73
+ export {
74
+ devWarn,
75
+ devError,
76
+ setDevFeedbackSink,
77
+ resetDevFeedback,
78
+ } from './dev-feedback.js'
79
+ export {
80
+ libraryOriginError,
81
+ libraryOriginEnvelope,
82
+ stackFingerprint,
83
+ isLibraryOriginError,
84
+ captureLibraryOriginError,
85
+ } from './library-origin-error.js'