@refraction-ui/astro 0.7.0 → 0.8.1
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-sink-app-insights/app-insights-sink.ts +344 -0
- package/dist/analytics-sink-app-insights/index.ts +14 -0
- package/dist/analytics-sink-app-insights/mapping.ts +171 -0
- package/dist/analytics-sink-ga4/client-sdk-sink.ts +191 -0
- package/dist/analytics-sink-ga4/ga4-sink.ts +27 -0
- package/dist/analytics-sink-ga4/http-sink.ts +141 -0
- package/dist/analytics-sink-ga4/index.ts +20 -0
- package/dist/analytics-sink-ga4/mapping.ts +172 -0
- package/dist/analytics-sink-ga4/types.ts +94 -0
- package/dist/analytics-sink-posthog/client-sdk-sink.ts +160 -0
- package/dist/analytics-sink-posthog/http-sink.ts +214 -0
- package/dist/analytics-sink-posthog/index.ts +26 -0
- package/dist/analytics-sink-posthog/mapping.ts +155 -0
- package/dist/analytics-sink-posthog/replay.ts +152 -0
- package/dist/analytics-sink-posthog/sink.ts +37 -0
- package/dist/astro-analytics/index.ts +45 -0
- package/dist/shared/library-origin-error.ts +1 -2
- package/dist/shared/types.ts +1 -1
- package/dist/theme/theme-script.ts +1 -1
- package/package.json +6 -2
|
@@ -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
|
+
}
|