@hanzo/event 0.3.48 → 0.3.49

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/src/types.ts CHANGED
@@ -1,15 +1,10 @@
1
1
  // Public types for the Hanzo Event client.
2
2
 
3
- /** The event kinds — the closed set the server understands. `error` marks the
4
- * breadcrumb an exception leaves on the event stream (Cloud stamps
5
- * event_type='error' for the warehouse, GET /v1/errors). It does NOT reach the
6
- * Sentry dashboard — the envelope on the error plane does that. */
3
+ /** The event kinds — the closed set the server understands. `error` is a captured
4
+ * exception; the server files it on the error plane. */
7
5
  export type EventKind = 'pageview' | 'event' | 'identify' | 'group' | 'error'
8
6
 
9
- /** A captured exception as it rides the EVENT STREAM — Cloud folds it into
10
- * properties.$exception for the warehouse. The richer copy (parsed stack frames,
11
- * grouping, release) travels on the error plane as a Sentry envelope; see
12
- * AnalyticsConfig.dsn. */
7
+ /** A captured exception, as the `error` field of a `type:'error'` event. */
13
8
  export interface Exception {
14
9
  /** Constructor/class name, e.g. "TypeError". */
15
10
  type?: string
@@ -20,6 +15,8 @@ export interface Exception {
20
15
  /** false = an unhandled/global error (window.onerror, unhandledrejection);
21
16
  * true = a caught error the app chose to report. Defaults true. */
22
17
  handled?: boolean
18
+ /** The structured stack, oldest first. */
19
+ frames?: { function?: string; file?: string; line?: number; column?: number }[]
23
20
  }
24
21
 
25
22
  /** One stack frame as Error Tracking renders it. The key names are the product's
@@ -125,9 +122,13 @@ export interface WireEvent {
125
122
  revenue?: number
126
123
  currency?: string
127
124
  /** Set on `type:'error'` events — the captured exception. Cloud lifts it into
128
- * properties.$exception (foldException) for the event warehouse. Not a Sentry
129
- * path: the envelope on the error plane is what feeds the dashboard. */
125
+ * properties.$exception (foldException) for the event warehouse. The
126
+ * server files it on the error plane. */
130
127
  error?: Exception
128
+ level?: string
129
+ release?: string
130
+ environment?: string
131
+ site?: string
131
132
  properties?: Record<string, unknown>
132
133
  library?: string
133
134
  libraryVersion?: string
@@ -176,8 +177,7 @@ export interface AnalyticsConfig {
176
177
  * POST /v1/keys {"type":"publishable"}.
177
178
  *
178
179
  * Omit it and the client reads NEXT_PUBLIC_PUBLISHABLE_KEY from the inlined
179
- * build env, the same way `dsn` falls back — so a surface declares BOTH planes
180
- * in its build and neither needs code to switch on. That is the ONE spelling
180
+ * build env, That is the ONE spelling
181
181
  * the fleet already carries: KMS holds deploy/PUBLISHABLE_KEY, and each
182
182
  * Dockerfile takes PUBLISHABLE_KEY as a build-arg and re-exports it with the
183
183
  * NEXT_PUBLIC_ prefix that makes Next inline it.
@@ -187,9 +187,7 @@ export interface AnalyticsConfig {
187
187
  * edge refuses an unattributable write rather than filing it where its owner
188
188
  * cannot read it. That failure is invisible from the page, which is why the key
189
189
  * belongs in the env next to the DSN and not in a checklist.
190
- *
191
- * This attributes the EVENT STREAM only — the error plane authenticates
192
- * independently with `dsn`, and one does not stand in for the other. */
190
+ */
193
191
  ingestKey?: string
194
192
  /** Max events buffered before an automatic flush. */
195
193
  batchSize?: number
@@ -198,24 +196,13 @@ export interface AnalyticsConfig {
198
196
  /** Turn the client off entirely (e.g. opt-out / DNT). Defaults to enabled. */
199
197
  enabled?: boolean
200
198
  /** Auto-capture unhandled errors + promise rejections (window.onerror,
201
- * unhandledrejection). Browser-only, defaults to enabled. Together with `dsn`
202
- * this is what makes the client a drop-in @sentry replacement — without a
203
- * `dsn` the captures never reach the Sentry dashboard. */
199
+ * unhandledrejection). Browser-only, defaults to enabled. */
204
200
  captureErrors?: boolean
205
201
  /** Override the transport (tests). */
206
202
  transport?: Transport
207
203
  /** Debug logging. */
208
204
  debug?: boolean
209
205
 
210
- // ── error plane (Sentry envelope -> sentry.hanzo.ai) ──────────────────────
211
-
212
- /** Hanzo-minted Sentry DSN: "https://<version>:<hmac>@<host>/v1/event/<projectId>".
213
- * Publishable — the key authorizes writes to ONE project and can read nothing,
214
- * so it is safe in a browser bundle (same trust class as `ingestKey`). When
215
- * absent the client reads NEXT_PUBLIC_HANZO_EVENT_DSN; when neither is set the
216
- * error plane is inert (fail-safe: nothing sent, nothing thrown, analytics
217
- * unaffected). Mint one per property: POST /v1/sentry/projects. */
218
- dsn?: string
219
206
  /** Release stamped on error events (a git SHA / app version). */
220
207
  release?: string
221
208
  /** Deployment environment for error events (production | staging | …). */
@@ -225,8 +212,7 @@ export interface AnalyticsConfig {
225
212
  capturePII?: boolean
226
213
  }
227
214
 
228
- // ── Sentry envelope wire types (a from-scratch model of the PUBLIC, documented
229
- // Sentry ingest protocol — develop.sentry.dev; no upstream code) ───────────
215
+ // ── stack frame and level types ──────────────────────────────────────────────
230
216
 
231
217
  export type SentryLevel = 'fatal' | 'error' | 'warning' | 'info' | 'debug'
232
218
 
@@ -240,52 +226,6 @@ export interface SentryFrame {
240
226
  in_app?: boolean
241
227
  }
242
228
 
243
- export interface SentryExceptionValue {
244
- type?: string
245
- value?: string
246
- module?: string
247
- stacktrace?: { frames: SentryFrame[] }
248
- }
249
-
250
- export interface SentryUser {
251
- /** Stable subject id (OIDC sub / anon id). NEVER email/username/ip. */
252
- id?: string
253
- }
254
-
255
- export interface SentryEvent {
256
- event_id: string
257
- timestamp: number
258
- platform: 'javascript'
259
- level: SentryLevel
260
- logger?: string
261
- environment?: string
262
- release?: string
263
- transaction?: string
264
- fingerprint?: string[]
265
- message?: string
266
- exception?: { values: SentryExceptionValue[] }
267
- tags?: Record<string, string>
268
- user?: SentryUser
269
- contexts?: Record<string, Record<string, unknown>>
270
- sdk?: { name: string; version: string }
271
- }
272
-
273
- /** Parsed DSN — the public key + the derived ingest URL. */
274
- export interface Dsn {
275
- /** "<version>:<hmac>" public key presented via ?sentry_key= (beacon-safe). */
276
- publicKey: string
277
- /** Ingest origin, e.g. "https://sentry.hanzo.ai". */
278
- origin: string
279
- /** Project id segment. */
280
- projectId: string
281
- /** The DSN's own origin + path, e.g. "https://api.hanzo.ai/v1/event/<projectId>".
282
- * Every URL below is derived from this, so the ingest address is named once —
283
- * in dsnForProduct — and nowhere else. */
284
- base: string
285
- /** Fully-derived envelope ingest URL incl. ?sentry_key=. */
286
- ingestUrl: string
287
- }
288
-
289
229
  /** Options for Analytics.captureError. */
290
230
  export interface CaptureErrorOptions {
291
231
  /** false => uncaught (window.onerror / unhandledrejection / render crash). */
package/src/uid.test.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, afterEach } from 'vitest'
2
2
  import { uuidv7, uuidv7Time } from './uid'
3
- import { eventId } from './sentry'
3
+ const eventId = () => uuidv7().replace(/-/g, '')
4
4
 
5
5
  /** The event plane's admission gate, transcribed from the session rollup's own SQL:
6
6
  * `bitAnd(bitShiftRight(toUInt128(accurateCastOrNull(id,'UUID')), 76), 15) = 7`.
package/dist/dsn.d.ts DELETED
@@ -1,43 +0,0 @@
1
- /**
2
- * The product → Sentinel DSN registry.
3
- *
4
- * An app declares WHAT it is (`product: 'console'`); this module knows WHERE its
5
- * errors go. That split is the whole point: no surface has to learn a DSN, carry
6
- * a build argument, or grow a config file to report errors — declaring the
7
- * product it already declares is enough.
8
- *
9
- * A Sentinel DSN is PUBLIC by construction. It ships inside the client bundle and
10
- * is readable in devtools on any deployed page, and it grants exactly one
11
- * capability: submitting new events. It cannot read issues, projects, or any
12
- * other data. So committing it is not leaking a secret — it is recording a public
13
- * identifier next to the code that needs it. (Contrast the server-side collector
14
- * DSN in the `team-analytics-sentry` Secret, which is HMAC-derived and revocable
15
- * precisely because a server-side credential is NOT public.)
16
- *
17
- * Why a literal map instead of deriving `hanzo-${product}`: the projects predate
18
- * this registry and do not derive cleanly — `site` lives in `hanzo-ai`, not
19
- * `hanzo-site`. An explicit map is honest about that; a derivation rule plus an
20
- * exception table is the same data with a trap in it.
21
- *
22
- * Projects are org-scoped and named `<org>-<app>`. To add one: create the project
23
- * (POST /v1/sentinel/projects with X-Org-Id), then add its `dsn` here keyed by the
24
- * product name the app passes to `createAnalytics`.
25
- */
26
- /** PRODUCT_PROJECT maps a `product` to its Sentinel project id. The DSN's KEY is no
27
- * longer a per-project secret — it is the ONE org publishable key (below), so a
28
- * surface's errors ride the SAME key its events do. The id only names WHICH
29
- * project the errors group under, and cloud auto-provisions that project on first
30
- * keyed ingest, so a new id needs nothing minted. `site` lives in the `hanzo-ai`
31
- * project — an explicit map, because the projects predate this and do not derive
32
- * cleanly from the product name. */
33
- export declare const PRODUCT_PROJECT: Readonly<Record<string, string>>;
34
- /** dsnForProduct builds the product's Sentinel DSN from the caller's resolved
35
- * publishable `key` and the product's project — the SAME key the event stream
36
- * carries, at the product's envelope endpoint. Returns undefined when there is no
37
- * key or no project for the product, leaving the error plane inert rather than
38
- * posting into the wrong one. The key is NOT baked here: it is the value the
39
- * surface resolved (an explicit `ingestKey`, or the KMS-sourced
40
- * NEXT_PUBLIC_PUBLISHABLE_KEY the build inlines) — the ONE live source, never a
41
- * literal committed beside the code. */
42
- export declare function dsnForProduct(product: string | undefined, key: string | undefined): string | undefined;
43
- //# sourceMappingURL=dsn.d.ts.map
package/dist/dsn.d.ts.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"dsn.d.ts","sourceRoot":"","sources":["../src/dsn.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH;;;;;;qCAMqC;AACrC,eAAO,MAAM,eAAe,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAI3D,CAAA;AAEF;;;;;;;yCAOyC;AACzC,wBAAgB,aAAa,CAC3B,OAAO,EAAE,MAAM,GAAG,SAAS,EAC3B,GAAG,EAAE,MAAM,GAAG,SAAS,GACtB,MAAM,GAAG,SAAS,CAKpB"}
package/dist/sentry.d.ts DELETED
@@ -1,62 +0,0 @@
1
- import type { CaptureErrorOptions, Dsn, SentryEvent, SentryFrame } from './types';
2
- /** eventId mints a 32-hex-char id (no dashes) — the Sentry event_id shape. Same
3
- * minter as everything else, just formatted for Sentry's wire. */
4
- export declare function eventId(): string;
5
- /**
6
- * parseDsn parses "https://<version>:<hmac>@<host>/v1/event/<projectId>" into its
7
- * public key + the derived envelope ingest URL. The key (which itself contains a
8
- * ':') is taken verbatim as the userinfo — we do NOT split it as user:pass. The
9
- * key rides ?sentry_key= (not the DSN in the body) because that is the credential
10
- * channel the server trusts AND the only one sendBeacon can carry on unload.
11
- * Returns null for anything malformed (fail-safe: the caller then stays inert).
12
- */
13
- export declare function parseDsn(dsn: string | undefined | null): Dsn | null;
14
- /**
15
- * framesFromStack parses a browser Error.stack into Sentry frames, OLDEST-FIRST
16
- * (Sentry orders caller->callee; the crash site is last — matching the server's
17
- * pickCrashFrame). Handles both V8 ("at fn (file:li:co)") and
18
- * Firefox/Safari ("fn@file:li:co"). Unparseable lines are skipped.
19
- */
20
- export declare function framesFromStack(stack: string | undefined): SentryFrame[];
21
- /** normalizeError coerces an unknown throwable into {name, message, stack}. */
22
- export declare function normalizeError(err: unknown): {
23
- name: string;
24
- message: string;
25
- stack?: string;
26
- };
27
- /** Identity carried onto every error event — the SAME ids analytics uses. */
28
- export interface ErrorIdentity {
29
- /** OIDC sub (post-identify) or anon id. NEVER email/PII. */
30
- userId?: string;
31
- sessionId?: string;
32
- product?: string;
33
- release?: string;
34
- environment?: string;
35
- }
36
- export interface BuildEventInput {
37
- error: unknown;
38
- options?: CaptureErrorOptions;
39
- identity: ErrorIdentity;
40
- capturePII?: boolean;
41
- /** Injectable for deterministic tests. */
42
- now?: number;
43
- id?: string;
44
- }
45
- /**
46
- * buildSentryEvent turns a throwable + identity into a Sentry `event`. The message
47
- * (the leak surface) is scrubbed client-side; the user is ONLY the stable subject
48
- * id — never email/username/ip. Level defaults to error, or fatal for uncaught.
49
- */
50
- export declare function buildSentryEvent(input: BuildEventInput): SentryEvent;
51
- /**
52
- * buildEnvelope frames a Sentry event into a newline-delimited envelope:
53
- *
54
- * {"event_id","dsn","sent_at"}\n
55
- * {"type":"event","content_type":"application/json","length":N}\n
56
- * <event json>\n
57
- *
58
- * The item is length-delimited (N = UTF-8 byte length) — the framing the server's
59
- * parseEnvelope reads first (falling back to newline-delimited otherwise).
60
- */
61
- export declare function buildEnvelope(event: SentryEvent, dsn: Dsn, sentAt?: string): string;
62
- //# sourceMappingURL=sentry.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"sentry.d.ts","sourceRoot":"","sources":["../src/sentry.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EACV,mBAAmB,EACnB,GAAG,EACH,WAAW,EACX,WAAW,EAEZ,MAAM,SAAS,CAAA;AAahB;mEACmE;AACnE,wBAAgB,OAAO,IAAI,MAAM,CAEhC;AAUD;;;;;;;GAOG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAAG,GAAG,GAAG,IAAI,CAqBnE;AAiBD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,WAAW,EAAE,CAkDxE;AAED,+EAA+E;AAC/E,wBAAgB,cAAc,CAAC,GAAG,EAAE,OAAO,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAoB9F;AAsBD,6EAA6E;AAC7E,MAAM,WAAW,aAAa;IAC5B,4DAA4D;IAC5D,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,OAAO,CAAA;IACd,OAAO,CAAC,EAAE,mBAAmB,CAAA;IAC7B,QAAQ,EAAE,aAAa,CAAA;IACvB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,0CAA0C;IAC1C,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,EAAE,CAAC,EAAE,MAAM,CAAA;CACZ;AAiBD;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,eAAe,GAAG,WAAW,CAqDpE;AAED;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,WAAW,EAAE,GAAG,EAAE,GAAG,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAanF"}
package/src/dsn.test.ts DELETED
@@ -1,75 +0,0 @@
1
- import { describe, it, expect, afterEach } from 'vitest'
2
-
3
- import { createAnalytics } from './core'
4
- import { PRODUCT_PROJECT, dsnForProduct } from './dsn'
5
- import { parseDsn } from './sentry'
6
-
7
- const ENV = 'NEXT_PUBLIC_HANZO_EVENT_DSN'
8
- const OVERRIDE = 'https://1:aaaa@api.hanzo.ai/v1/event/env-project'
9
- const EXPLICIT = 'https://1:bbbb@api.hanzo.ai/v1/event/explicit-project'
10
- // A stand-in for the surface's own resolved key — the DSN carries whatever key
11
- // the caller resolved (config or the KMS-sourced env), never a literal.
12
- const KEY = 'pk-test-resolved-key'
13
-
14
- afterEach(() => {
15
- delete process.env[ENV]
16
- })
17
-
18
- describe('the product registry', () => {
19
- it('builds a product DSN from the CALLER key + the project — nothing baked', () => {
20
- expect(dsnForProduct('console', KEY)).toBe(
21
- `https://${KEY}@api.hanzo.ai/v1/event/${PRODUCT_PROJECT.console}`
22
- )
23
- expect(dsnForProduct('site', KEY)).toBe(
24
- `https://${KEY}@api.hanzo.ai/v1/event/${PRODUCT_PROJECT.site}`
25
- )
26
- })
27
-
28
- it('returns undefined without a key or without a project, rather than guessing', () => {
29
- expect(dsnForProduct('console', undefined)).toBeUndefined() // no key -> inert
30
- expect(dsnForProduct('not-a-product', KEY)).toBeUndefined()
31
- expect(dsnForProduct(undefined, KEY)).toBeUndefined()
32
- expect(dsnForProduct('', KEY)).toBeUndefined()
33
- })
34
-
35
- it('every product DSN parses to the caller key + its own project id', () => {
36
- for (const [product, projectId] of Object.entries(PRODUCT_PROJECT)) {
37
- const parsed = parseDsn(dsnForProduct(product, KEY))
38
- expect(parsed, `${product} DSN must parse`).not.toBeNull()
39
- expect(parsed!.projectId, `${product} project id`).toBe(projectId)
40
- expect(parsed!.publicKey, `${product} carries the resolved key`).toBe(KEY)
41
- }
42
- })
43
- })
44
-
45
- describe('DSN precedence — most specific source wins', () => {
46
- it('lights up the error plane from `product` + a resolved key, with no explicit dsn', () => {
47
- const a = createAnalytics({ product: 'console', ingestKey: KEY, enabled: false })
48
- expect(a.errorPlaneEnabled).toBe(true)
49
- expect(a.errorIngestUrl).toContain(PRODUCT_PROJECT.console)
50
- })
51
-
52
- it('prefers an explicit dsn over both the env and the registry', () => {
53
- process.env[ENV] = OVERRIDE
54
- const a = createAnalytics({ product: 'console', ingestKey: KEY, dsn: EXPLICIT, enabled: false })
55
- expect(a.errorIngestUrl).toContain('explicit-project')
56
- })
57
-
58
- it('prefers the env override over the registry, so a deploy can repoint a surface', () => {
59
- process.env[ENV] = OVERRIDE
60
- const a = createAnalytics({ product: 'console', ingestKey: KEY, enabled: false })
61
- expect(a.errorIngestUrl).toContain('env-project')
62
- })
63
-
64
- it('stays inert for an unregistered product — never posts one surface into another project', () => {
65
- const a = createAnalytics({ product: 'not-a-product', ingestKey: KEY, enabled: false })
66
- expect(a.errorPlaneEnabled).toBe(false)
67
- expect(a.errorIngestUrl).toBeUndefined()
68
- })
69
-
70
- it('stays inert with a product but no key — the error plane needs the live key', () => {
71
- const a = createAnalytics({ product: 'console', enabled: false })
72
- expect(a.errorPlaneEnabled).toBe(false)
73
- expect(a.errorIngestUrl).toBeUndefined()
74
- })
75
- })
package/src/dsn.ts DELETED
@@ -1,56 +0,0 @@
1
- /**
2
- * The product → Sentinel DSN registry.
3
- *
4
- * An app declares WHAT it is (`product: 'console'`); this module knows WHERE its
5
- * errors go. That split is the whole point: no surface has to learn a DSN, carry
6
- * a build argument, or grow a config file to report errors — declaring the
7
- * product it already declares is enough.
8
- *
9
- * A Sentinel DSN is PUBLIC by construction. It ships inside the client bundle and
10
- * is readable in devtools on any deployed page, and it grants exactly one
11
- * capability: submitting new events. It cannot read issues, projects, or any
12
- * other data. So committing it is not leaking a secret — it is recording a public
13
- * identifier next to the code that needs it. (Contrast the server-side collector
14
- * DSN in the `team-analytics-sentry` Secret, which is HMAC-derived and revocable
15
- * precisely because a server-side credential is NOT public.)
16
- *
17
- * Why a literal map instead of deriving `hanzo-${product}`: the projects predate
18
- * this registry and do not derive cleanly — `site` lives in `hanzo-ai`, not
19
- * `hanzo-site`. An explicit map is honest about that; a derivation rule plus an
20
- * exception table is the same data with a trap in it.
21
- *
22
- * Projects are org-scoped and named `<org>-<app>`. To add one: create the project
23
- * (POST /v1/sentinel/projects with X-Org-Id), then add its `dsn` here keyed by the
24
- * product name the app passes to `createAnalytics`.
25
- */
26
-
27
- /** PRODUCT_PROJECT maps a `product` to its Sentinel project id. The DSN's KEY is no
28
- * longer a per-project secret — it is the ONE org publishable key (below), so a
29
- * surface's errors ride the SAME key its events do. The id only names WHICH
30
- * project the errors group under, and cloud auto-provisions that project on first
31
- * keyed ingest, so a new id needs nothing minted. `site` lives in the `hanzo-ai`
32
- * project — an explicit map, because the projects predate this and do not derive
33
- * cleanly from the product name. */
34
- export const PRODUCT_PROJECT: Readonly<Record<string, string>> = Object.freeze({
35
- console: '019fa40b-94ae-7f1d-8f7b-e92f123fad42', // console.hanzo.ai
36
- app: '019f9b1e-57eb-7171-9d92-72c0b85e4b4b', // hanzo.app
37
- site: '019f9b1e-5785-7359-ad0b-f75db8e58c99', // hanzo.ai (marketing; product `site`)
38
- })
39
-
40
- /** dsnForProduct builds the product's Sentinel DSN from the caller's resolved
41
- * publishable `key` and the product's project — the SAME key the event stream
42
- * carries, at the product's envelope endpoint. Returns undefined when there is no
43
- * key or no project for the product, leaving the error plane inert rather than
44
- * posting into the wrong one. The key is NOT baked here: it is the value the
45
- * surface resolved (an explicit `ingestKey`, or the KMS-sourced
46
- * NEXT_PUBLIC_PUBLISHABLE_KEY the build inlines) — the ONE live source, never a
47
- * literal committed beside the code. */
48
- export function dsnForProduct(
49
- product: string | undefined,
50
- key: string | undefined
51
- ): string | undefined {
52
- if (!product || !key) return undefined
53
- const projectId = PRODUCT_PROJECT[product]
54
- if (!projectId) return undefined
55
- return `https://${key}@api.hanzo.ai/v1/event/${projectId}`
56
- }