@refraction-ui/astro 0.7.0 → 0.8.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,344 @@
1
+ import type {
2
+ AnalyticsEvent,
3
+ AnalyticsSink,
4
+ SinkDeliverContext,
5
+ } from '../analytics/index.ts'
6
+ import { mapEvent, eventName } from './mapping.js'
7
+
8
+ /**
9
+ * Minimal structural shape of the bits of `@microsoft/applicationinsights-web`
10
+ * we use. Declared locally so the vendor lib stays an OPTIONAL peer with NO
11
+ * type-level hard dependency — it is only ever reached via dynamic import.
12
+ */
13
+ export interface AppInsightsLike {
14
+ loadAppInsights?: () => unknown
15
+ trackEvent: (
16
+ event: { name: string },
17
+ customProperties?: Record<string, unknown>,
18
+ ) => void
19
+ trackPageView: (pageView?: {
20
+ name?: string
21
+ uri?: string
22
+ properties?: Record<string, unknown>
23
+ measurements?: Record<string, number>
24
+ }) => void
25
+ setAuthenticatedUserContext?: (
26
+ authenticatedUserId: string,
27
+ accountId?: string,
28
+ storeInCookie?: boolean,
29
+ ) => void
30
+ clearAuthenticatedUserContext?: () => void
31
+ flush?: (async?: boolean) => void
32
+ }
33
+
34
+ /** Fan-out mode for the App Insights sink. */
35
+ export type AppInsightsSinkMode = 'client-sdk' | 'http'
36
+
37
+ interface BaseOptions {
38
+ /** Consent categories this sink requires. Default `['analytics']`. */
39
+ consentCategories?: string[]
40
+ /** Stable sink name. Default `'app-insights'`. */
41
+ name?: string
42
+ }
43
+
44
+ /**
45
+ * `client-sdk` mode — runs in the browser via `@microsoft/applicationinsights-web`.
46
+ *
47
+ * The vendor SDK is loaded **lazily via dynamic import** the first time a
48
+ * batch is delivered, so it never enters the bundle unless this sink is used.
49
+ * Callers may instead inject a ready instance (`appInsights`) or a custom
50
+ * `loadSdk` factory (used by tests with a mock transport — no network).
51
+ */
52
+ export interface ClientSdkOptions extends BaseOptions {
53
+ mode: 'client-sdk'
54
+ /** App Insights connection string (preferred) or instrumentation key. */
55
+ connectionString?: string
56
+ /** Legacy instrumentation key (used when `connectionString` is absent). */
57
+ instrumentationKey?: string
58
+ /**
59
+ * Pre-constructed App Insights instance. When provided, no dynamic import
60
+ * or `loadAppInsights()` happens — used for DI/testing.
61
+ */
62
+ appInsights?: AppInsightsLike
63
+ /**
64
+ * Custom async SDK factory. Overrides the built-in dynamic import of
65
+ * `@microsoft/applicationinsights-web`. Receives the resolved config.
66
+ */
67
+ loadSdk?: (config: {
68
+ connectionString?: string
69
+ instrumentationKey?: string
70
+ }) => Promise<AppInsightsLike>
71
+ }
72
+
73
+ /**
74
+ * `http` mode — POSTs to the App Insights `/v2/track` ingestion endpoint.
75
+ * No browser library is loaded; server-relay friendly.
76
+ */
77
+ export interface HttpOptions extends BaseOptions {
78
+ mode: 'http'
79
+ /** Instrumentation key (iKey) for the ingestion envelope. Required. */
80
+ instrumentationKey: string
81
+ /**
82
+ * Ingestion endpoint base. Default the public global endpoint
83
+ * `https://dc.services.visualstudio.com`. Region endpoints are accepted.
84
+ * Events POST to `{endpoint}/v2/track`.
85
+ */
86
+ endpoint?: string
87
+ /** Injected fetch (defaults to global fetch). */
88
+ fetchImpl?: typeof fetch
89
+ /** Max retries for 429/5xx (exponential backoff). Default 3. */
90
+ maxRetries?: number
91
+ /** Base backoff delay in ms. Default 500. */
92
+ backoffBaseMs?: number
93
+ }
94
+
95
+ export type AppInsightsSinkOptions = ClientSdkOptions | HttpOptions
96
+
97
+ const DEFAULT_INGEST_ENDPOINT = 'https://dc.services.visualstudio.com'
98
+ const NO_RETRY = new Set([400, 401, 403, 413])
99
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
100
+
101
+ /* ------------------------------------------------------------------ */
102
+ /* client-sdk mode */
103
+ /* ------------------------------------------------------------------ */
104
+
105
+ function createClientSdkSink(opts: ClientSdkOptions): AnalyticsSink {
106
+ const name = opts.name ?? 'app-insights'
107
+ let instance: AppInsightsLike | undefined = opts.appInsights
108
+ let loading: Promise<AppInsightsLike> | undefined
109
+
110
+ async function resolveSdk(): Promise<AppInsightsLike> {
111
+ if (instance) return instance
112
+ if (loading) return loading
113
+ loading = (async () => {
114
+ if (opts.loadSdk) {
115
+ instance = await opts.loadSdk({
116
+ connectionString: opts.connectionString,
117
+ instrumentationKey: opts.instrumentationKey,
118
+ })
119
+ return instance
120
+ }
121
+ // Lazy, dynamic, optional — never a static/hard dependency.
122
+ const mod = (await import(
123
+ /* @vite-ignore */ '@microsoft/applicationinsights-web'
124
+ )) as {
125
+ ApplicationInsights: new (cfg: {
126
+ config: {
127
+ connectionString?: string
128
+ instrumentationKey?: string
129
+ }
130
+ }) => AppInsightsLike
131
+ }
132
+ const ai = new mod.ApplicationInsights({
133
+ config: {
134
+ connectionString: opts.connectionString,
135
+ instrumentationKey: opts.instrumentationKey,
136
+ },
137
+ })
138
+ ai.loadAppInsights?.()
139
+ instance = ai
140
+ return ai
141
+ })()
142
+ return loading
143
+ }
144
+
145
+ let lastAuthUserId: string | undefined
146
+
147
+ return {
148
+ name,
149
+ consentCategories: opts.consentCategories ?? ['analytics'],
150
+
151
+ async deliver(batch: AnalyticsEvent[]): Promise<void> {
152
+ if (batch.length === 0) return
153
+ const ai = await resolveSdk()
154
+
155
+ for (const ev of batch) {
156
+ // Identity → App Insights authenticated-user context.
157
+ if (ev.userId !== undefined && ev.userId !== lastAuthUserId) {
158
+ ai.setAuthenticatedUserContext?.(ev.userId)
159
+ lastAuthUserId = ev.userId
160
+ }
161
+ if (
162
+ ev.type === 'identify' &&
163
+ ev.userId === undefined &&
164
+ lastAuthUserId !== undefined
165
+ ) {
166
+ ai.clearAuthenticatedUserContext?.()
167
+ lastAuthUserId = undefined
168
+ }
169
+
170
+ const mapped = mapEvent(ev)
171
+ const merged: Record<string, unknown> = {
172
+ ...mapped.properties,
173
+ ...mapped.measurements,
174
+ }
175
+
176
+ if (ev.type === 'page') {
177
+ ai.trackPageView({
178
+ name: ev.event ?? ev.context.page?.path,
179
+ uri: ev.context.page?.url,
180
+ properties: mapped.properties,
181
+ measurements: mapped.measurements,
182
+ })
183
+ } else {
184
+ ai.trackEvent({ name: mapped.name }, merged)
185
+ }
186
+ }
187
+ },
188
+
189
+ async flush(): Promise<void> {
190
+ if (instance?.flush) instance.flush(false)
191
+ },
192
+
193
+ shutdown(): void {
194
+ instance = undefined
195
+ loading = undefined
196
+ lastAuthUserId = undefined
197
+ },
198
+ }
199
+ }
200
+
201
+ /* ------------------------------------------------------------------ */
202
+ /* http mode — App Insights /v2/track ingestion */
203
+ /* ------------------------------------------------------------------ */
204
+
205
+ interface IngestEnvelope {
206
+ name: string
207
+ time: string
208
+ iKey: string
209
+ tags: Record<string, string>
210
+ data: {
211
+ baseType: 'EventData' | 'PageViewData'
212
+ baseData: {
213
+ ver: 2
214
+ name: string
215
+ properties: Record<string, string>
216
+ measurements: Record<string, number>
217
+ }
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Build an App Insights ingestion envelope (Breeze schema) for one canonical
223
+ * event. `tags` carry session/user/operation correlation; `baseData` carries
224
+ * the custom event with the properties/measurements split.
225
+ */
226
+ export function buildIngestEnvelope(
227
+ ev: AnalyticsEvent,
228
+ iKey: string,
229
+ ): IngestEnvelope {
230
+ const mapped = mapEvent(ev)
231
+ const isPage = ev.type === 'page'
232
+ const tags: Record<string, string> = {
233
+ 'ai.session.id': ev.sessionId,
234
+ 'ai.user.id': ev.anonymousId,
235
+ 'ai.operation.id': ev.messageId,
236
+ 'ai.internal.sdkVersion': `refraction-ui-analytics-app-insights:0.1.0`,
237
+ 'ai.application.ver': ev.context.library.version,
238
+ 'ai.cloud.role': ev.context.app,
239
+ 'ai.cloud.roleInstance': ev.context.env,
240
+ }
241
+ if (ev.userId !== undefined) tags['ai.user.authUserId'] = ev.userId
242
+
243
+ return {
244
+ name: isPage
245
+ ? 'Microsoft.ApplicationInsights.PageView'
246
+ : 'Microsoft.ApplicationInsights.Event',
247
+ time: ev.timestamp,
248
+ iKey,
249
+ tags,
250
+ data: {
251
+ baseType: isPage ? 'PageViewData' : 'EventData',
252
+ baseData: {
253
+ ver: 2,
254
+ name: isPage ? eventName(ev) : mapped.name,
255
+ properties: mapped.properties,
256
+ measurements: mapped.measurements,
257
+ },
258
+ },
259
+ }
260
+ }
261
+
262
+ function createHttpSink(opts: HttpOptions): AnalyticsSink {
263
+ const name = opts.name ?? 'app-insights'
264
+ const {
265
+ instrumentationKey,
266
+ maxRetries = 3,
267
+ backoffBaseMs = 500,
268
+ } = opts
269
+ const base = (opts.endpoint ?? DEFAULT_INGEST_ENDPOINT).replace(/\/+$/, '')
270
+ const url = `${base}/v2/track`
271
+
272
+ const resolveFetch = (): typeof fetch => {
273
+ if (opts.fetchImpl) return opts.fetchImpl
274
+ const f = (globalThis as unknown as { fetch?: typeof fetch }).fetch
275
+ if (!f) throw new Error('No fetch implementation available')
276
+ return f
277
+ }
278
+
279
+ async function send(body: string): Promise<void> {
280
+ const doFetch = resolveFetch()
281
+ for (let attempt = 0; ; attempt++) {
282
+ let status: number
283
+ try {
284
+ const res = await doFetch(url, {
285
+ method: 'POST',
286
+ headers: { 'Content-Type': 'application/json' },
287
+ body,
288
+ keepalive: true,
289
+ })
290
+ status = res.status
291
+ } catch {
292
+ status = 0 // network error → transient
293
+ }
294
+ if (status >= 200 && status < 300) return
295
+ if (NO_RETRY.has(status)) return
296
+ if (attempt >= maxRetries) return
297
+ await sleep(backoffBaseMs * 2 ** attempt)
298
+ }
299
+ }
300
+
301
+ return {
302
+ name,
303
+ consentCategories: opts.consentCategories ?? ['analytics'],
304
+
305
+ async deliver(
306
+ batch: AnalyticsEvent[],
307
+ _ctx: SinkDeliverContext,
308
+ ): Promise<void> {
309
+ if (batch.length === 0) return
310
+ const envelopes = batch.map((ev) =>
311
+ buildIngestEnvelope(ev, instrumentationKey),
312
+ )
313
+ // App Insights ingestion accepts a JSON array of envelopes (it also
314
+ // accepts newline-delimited; the array form is the documented batch).
315
+ await send(JSON.stringify(envelopes))
316
+ },
317
+ }
318
+ }
319
+
320
+ /* ------------------------------------------------------------------ */
321
+ /* factory */
322
+ /* ------------------------------------------------------------------ */
323
+
324
+ /**
325
+ * Create an Azure Application Insights {@link AnalyticsSink}.
326
+ *
327
+ * Dual-mode:
328
+ * - `client-sdk` — browser, lazy `@microsoft/applicationinsights-web`
329
+ * (`trackEvent` / `trackPageView`); the vendor lib is an OPTIONAL peer
330
+ * loaded only via dynamic import, never a hard/static dependency.
331
+ * - `http` — App Insights `/v2/track` ingestion endpoint; no browser lib,
332
+ * server-relay friendly.
333
+ *
334
+ * Both modes map the canonical envelope to App Insights custom events with a
335
+ * properties/measurements split and surface identity as
336
+ * `authenticatedUserId` / `anonymous`.
337
+ */
338
+ export function createAppInsightsSink(
339
+ options: AppInsightsSinkOptions,
340
+ ): AnalyticsSink {
341
+ return options.mode === 'http'
342
+ ? createHttpSink(options)
343
+ : createClientSdkSink(options)
344
+ }
@@ -0,0 +1,14 @@
1
+ // Azure Application Insights sink for @refraction-ui/analytics.
2
+ export { createAppInsightsSink } from './app-insights-sink.js'
3
+ export type {
4
+ AppInsightsSinkOptions,
5
+ AppInsightsSinkMode,
6
+ ClientSdkOptions,
7
+ HttpOptions,
8
+ AppInsightsLike,
9
+ } from './app-insights-sink.js'
10
+ export { buildIngestEnvelope } from './app-insights-sink.js'
11
+
12
+ // Envelope → App Insights custom-event mapping (exported for advanced use).
13
+ export { mapEvent, eventName } from './mapping.js'
14
+ export type { AppInsightsEvent } from './mapping.js'
@@ -0,0 +1,171 @@
1
+ import type { AnalyticsEvent } from '../analytics/index.ts'
2
+
3
+ /**
4
+ * App Insights custom-event payload after mapping the canonical envelope.
5
+ *
6
+ * App Insights splits a custom event's bag into two dictionaries:
7
+ * - `properties` — string-valued dimensions (everything non-numeric)
8
+ * - `measurements`— numeric metrics (queryable/aggregatable in KQL)
9
+ *
10
+ * We additionally surface identity:
11
+ * - `authenticatedUserId` — present iff the envelope has `userId`
12
+ * - `anonymous` — `'true'` when no `userId`, else `'false'` (string for the
13
+ * properties bag; App Insights properties are always strings)
14
+ */
15
+ export interface AppInsightsEvent {
16
+ /** App Insights custom-event name. */
17
+ name: string
18
+ /** String-valued custom dimensions. */
19
+ properties: Record<string, string>
20
+ /** Numeric custom metrics. */
21
+ measurements: Record<string, number>
22
+ }
23
+
24
+ /** Track-call name fallback when the envelope omits `event`. */
25
+ const FALLBACK_TRACK_NAME = 'track'
26
+
27
+ /**
28
+ * Derive the App Insights custom-event name for a canonical event.
29
+ *
30
+ * - `page` → `Page View: <name|path>` (also routed to `trackPageView` in the
31
+ * client-sdk mode; the name here is the http-mode/event fallback)
32
+ * - `screen`→ `Screen: <name>`
33
+ * - `identify` → `Identify`
34
+ * - `group` → `Group`
35
+ * - `alias` → `Alias`
36
+ * - `track` → the event name (or `track` when absent)
37
+ */
38
+ export function eventName(ev: AnalyticsEvent): string {
39
+ switch (ev.type) {
40
+ case 'page':
41
+ return `Page View: ${ev.event ?? ev.context.page?.path ?? '(unknown)'}`
42
+ case 'screen':
43
+ return `Screen: ${ev.event ?? '(unknown)'}`
44
+ case 'identify':
45
+ return 'Identify'
46
+ case 'group':
47
+ return 'Group'
48
+ case 'alias':
49
+ return 'Alias'
50
+ case 'track':
51
+ default:
52
+ return ev.event ?? FALLBACK_TRACK_NAME
53
+ }
54
+ }
55
+
56
+ /** True for finite numbers (App Insights measurements must be finite). */
57
+ function isMeasurement(value: unknown): value is number {
58
+ return typeof value === 'number' && Number.isFinite(value)
59
+ }
60
+
61
+ /**
62
+ * Stringify a non-numeric value for the App Insights `properties` bag, which
63
+ * is string-only. Objects/arrays are JSON-encoded; everything else uses
64
+ * `String()`. `undefined`/`null` keys are skipped by the caller.
65
+ */
66
+ function stringify(value: unknown): string {
67
+ if (typeof value === 'string') return value
68
+ if (typeof value === 'boolean' || typeof value === 'bigint') {
69
+ return String(value)
70
+ }
71
+ if (typeof value === 'number') return String(value)
72
+ try {
73
+ return JSON.stringify(value) ?? String(value)
74
+ } catch {
75
+ return String(value)
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Split an arbitrary bag into App Insights `properties` (strings) and
81
+ * `measurements` (finite numbers), recursively flattening nested objects with
82
+ * dotted keys so KQL queries stay flat (App Insights does not index nested
83
+ * JSON in custom dimensions).
84
+ */
85
+ function splitBag(
86
+ bag: Record<string, unknown> | undefined,
87
+ prefix: string,
88
+ properties: Record<string, string>,
89
+ measurements: Record<string, number>,
90
+ ): void {
91
+ if (!bag) return
92
+ for (const [key, value] of Object.entries(bag)) {
93
+ if (value === undefined || value === null) continue
94
+ const flatKey = prefix ? `${prefix}.${key}` : key
95
+ if (isMeasurement(value)) {
96
+ measurements[flatKey] = value
97
+ continue
98
+ }
99
+ if (
100
+ typeof value === 'object' &&
101
+ !Array.isArray(value) &&
102
+ value.constructor === Object
103
+ ) {
104
+ splitBag(
105
+ value as Record<string, unknown>,
106
+ flatKey,
107
+ properties,
108
+ measurements,
109
+ )
110
+ continue
111
+ }
112
+ properties[flatKey] = stringify(value)
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Map a canonical {@link AnalyticsEvent} to an {@link AppInsightsEvent}.
118
+ *
119
+ * Properties bag composition:
120
+ * - canonical context: `app`, `env`, library name/version, page fields
121
+ * - envelope identity/routing: `messageId`, `anonymousId`, `sessionId`,
122
+ * `type`, `groupId?`, `previousId?`, `timestamp`, `schemaVersion`
123
+ * - identity surface: `authenticatedUserId` (iff `userId`), `anonymous`
124
+ * - the event's `properties` and `traits`, numbers → `measurements`
125
+ *
126
+ * The split is stable and lossless: every scalar lands in exactly one of the
127
+ * two dictionaries; nested objects are dot-flattened.
128
+ */
129
+ export function mapEvent(ev: AnalyticsEvent): AppInsightsEvent {
130
+ const properties: Record<string, string> = {}
131
+ const measurements: Record<string, number> = {}
132
+
133
+ // Identity → App Insights authenticated/anonymous surface.
134
+ if (ev.userId !== undefined) {
135
+ properties.authenticatedUserId = ev.userId
136
+ properties.anonymous = 'false'
137
+ } else {
138
+ properties.anonymous = 'true'
139
+ }
140
+
141
+ // Routing / envelope metadata (kept as queryable dimensions).
142
+ properties.messageId = ev.messageId
143
+ properties.anonymousId = ev.anonymousId
144
+ properties.sessionId = ev.sessionId
145
+ properties.eventType = ev.type
146
+ properties.timestamp = ev.timestamp
147
+ measurements.schemaVersion = ev.schemaVersion
148
+ if (ev.groupId !== undefined) properties.groupId = ev.groupId
149
+ if (ev.previousId !== undefined) properties.previousId = ev.previousId
150
+ if (ev.event !== undefined) properties.eventName = ev.event
151
+
152
+ // Canonical context.
153
+ properties.app = ev.context.app
154
+ properties.env = ev.context.env
155
+ properties.libraryName = ev.context.library.name
156
+ properties.libraryVersion = ev.context.library.version
157
+ if (ev.context.page) {
158
+ splitBag(
159
+ ev.context.page as Record<string, unknown>,
160
+ 'page',
161
+ properties,
162
+ measurements,
163
+ )
164
+ }
165
+
166
+ // Event payload + identify/group traits.
167
+ splitBag(ev.properties, '', properties, measurements)
168
+ splitBag(ev.traits, '', properties, measurements)
169
+
170
+ return { name: eventName(ev), properties, measurements }
171
+ }