@levo-so/insights 0.3.10 → 0.3.11

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,38 @@
1
+ /**
2
+ * Where events go, and whether that has been proven.
3
+ *
4
+ * A union rather than three nullable urls on the session. The three states are
5
+ * mutually exclusive, and the old shape (`insightsBaseUrl` / `candidateBaseUrl`
6
+ * / `fallbackBaseUrl`) let every reader re-derive which one it was in, and
7
+ * made "insightsBaseUrl is set" a comment-enforced invariant that `resolveApi`
8
+ * in the SDK app depended on. Here the invariant is the type.
9
+ */
10
+ type IEndpoint =
11
+ /** Nothing usable was configured. Events queue and are never sent. */
12
+ {
13
+ status: "none";
14
+ }
15
+ /**
16
+ * A proxy url that nothing has confirmed reaches Levo yet. The first event is
17
+ * the probe: its response either promotes this to `proven` or falls back.
18
+ * Always proxy, a direct host needs no proving.
19
+ */
20
+ | {
21
+ status: "unproven";
22
+ url: string;
23
+ fallback: string;
24
+ }
25
+ /** Confirmed: a request to this url came back as ours. */
26
+ | {
27
+ status: "proven";
28
+ url: string;
29
+ mode: "proxy" | "direct";
30
+ };
31
+ /** The url to send to, or null when there is nowhere to send. */
32
+ declare const endpointUrl: (endpoint: IEndpoint) => string | null;
33
+ /**
34
+ * How to authenticate to this endpoint. An unproven candidate is always proxy,
35
+ * where the browser attaches the identity cookies on a same-origin request.
36
+ */
37
+ declare const endpointMode: (endpoint: IEndpoint) => "proxy" | "direct";
38
+ export { type IEndpoint, endpointMode, endpointUrl, };
@@ -6,7 +6,6 @@ export interface IIdentity {
6
6
  locale: ILevoAudience.Traits["locale"] | null;
7
7
  timezone: ILevoAudience.Traits["timezone"] | null;
8
8
  darkMode: ILevoAudience.Traits["dark_mode"] | null;
9
- privateMode: ILevoAudience.Traits["private_mode"] | null;
10
9
  referrer: IIdentityReferrer | null;
11
10
  properties: ILevoAudience.Traits["properties"] | null;
12
11
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * First-party cookie helpers.
3
+ *
4
+ * Cookies are the only client store that is shared across tabs *and* readable by
5
+ * the server on the same request, which is why identity mirrors into them: the
6
+ * proxy-mode backend resolves `id_i_cd`/`id_i_cs` straight off the `Cookie`
7
+ * header without the SDK having to attach anything.
8
+ *
9
+ * All writes are host-only (no `Domain` attribute) and `path=/`, matching the
10
+ * scope the server uses for its own `id_i_d`/`id_i_s` cookies.
11
+ */
12
+ /** Read a cookie by name. Returns null when absent, blocked, or empty. */
13
+ declare const readCookie: (name: string) => string | null;
14
+ /**
15
+ * Write a host-only, path=/ cookie.
16
+ *
17
+ * `SameSite=Lax` (not `Strict`) so identity survives a cross-site entry, an ad
18
+ * or social click landing on the site is exactly the visit attribution cares
19
+ * about. `Secure` only on https, because localhost testbeds run over http and a
20
+ * Secure cookie there is silently dropped.
21
+ */
22
+ declare const writeCookie: (name: string, value: string, maxAgeSeconds: number) => void;
23
+ export { readCookie, writeCookie, };
@@ -1,10 +1,23 @@
1
+ import { IEndpoint } from '../types/endpoint';
1
2
  import { IInsightOptions } from '../types/options';
2
- type DetectionResult = {
3
- mode: "proxy" | "direct";
4
- url: string | null;
5
- };
6
3
  /**
7
- * Detect the insights mode (proxy vs direct).
4
+ * Decide where events should go, without asking the network first.
5
+ *
6
+ * This used to race a `GET /ping` against both endpoints and wait up to a
7
+ * second for the answer, and events queued for the whole of it. That cost the
8
+ * first-ever page load of every visitor a round trip *before* anything could
9
+ * be sent, which is the exact window a bouncing visitor leaves in, the ping
10
+ * had replaced `/welcome` as the thing standing between a quick exit and any
11
+ * record of it.
12
+ *
13
+ * The first event is now the probe. If the proxy is not really forwarding,
14
+ * that request fails on its own. Usually faster than a ping would have
15
+ * answered, because a missing route fails at the customer's edge, and the
16
+ * transport retries it against the direct host. A working proxy costs nothing
17
+ * at all: the event that proves it is an event that had to be sent anyway.
18
+ *
19
+ * Only "auto" with both urls configured needs proving. Anything else is
20
+ * already certain, and comes back `proven`.
8
21
  */
9
- export declare const detectMode: (options: IInsightOptions) => Promise<DetectionResult>;
10
- export {};
22
+ declare const detectMode: (options: IInsightOptions) => IEndpoint;
23
+ export { detectMode, };
@@ -0,0 +1,6 @@
1
+ declare const getAsyncUserProperties: () => Promise<{
2
+ private_mode: boolean | undefined;
3
+ properties: Record<string, unknown>;
4
+ } | null>;
5
+ type IAsyncUserProperties = NonNullable<Awaited<ReturnType<typeof getAsyncUserProperties>>>;
6
+ export { type IAsyncUserProperties, getAsyncUserProperties, };
@@ -1,9 +1,22 @@
1
1
  /**
2
- * Get authentication headers for direct mode.
3
- * Returns empty object in proxy mode (cookies handle auth).
2
+ * Format identity headers for one request in a known mode. Returns an empty
3
+ * object in proxy mode: the id_i_cd/id_i_cs cookies ride along automatically
4
+ * on the same-origin request, so no explicit header is needed there.
5
+ *
6
+ * Most callers want {@link getIdentityHeaders} in lib/identityHeaders.ts
7
+ * instead, which reads the SDK's current state and calls this. The transport
8
+ * is the exception: it formats the ids it captured at flush time for the mode
9
+ * of the attempt in hand, never live state.
10
+ *
11
+ * deviceToken (legacy direct-mode device JWT, pre-adoption) is sent alongside
12
+ * the client mint, not instead of it: the server ranks the legacy credential
13
+ * above the client one and echoes back whichever wins, exactly like proxy
14
+ * mode's automatic id_i_d/id_i_cd dual-attach. There is no session-JWT
15
+ * equivalent. Rotation is fully client-owned, sessions are never adopted.
4
16
  */
5
- export declare const getAuthHeaders: ({ deviceToken, sessionToken, mode, }: {
17
+ export declare const getAuthHeaders: ({ deviceId, sessionId, deviceToken, mode, }: {
18
+ deviceId?: string | null;
19
+ sessionId?: string | null;
6
20
  deviceToken?: string | null;
7
- sessionToken?: string | null;
8
21
  mode?: string | null;
9
22
  }) => Record<string, string>;
@@ -1,7 +1,32 @@
1
- export declare const getUserProperties: () => Promise<{
1
+ /**
2
+ * Everything the browser can answer *now*.
3
+ *
4
+ * Deliberately synchronous. This used to await three questions before anything
5
+ * else ran. Private-window detection, battery, and a camera/mic device
6
+ * enumeration, and only then read the values that were already sitting there.
7
+ * `enumerateDevices()` alone measured **154ms**, so the referrer, locale,
8
+ * timezone and screen size that decide a visit's attribution were held behind a
9
+ * check for whether the device has a webcam. Since the first event is what a
10
+ * bouncing visitor's whole visit depends on, that delay was the cost of the
11
+ * fields it was waiting for.
12
+ *
13
+ * Those probes live in getAsyncUserProperties() now, staged when they resolve
14
+ * and merged onto the same session row by the server, so nothing is lost
15
+ * except for a visitor who leaves inside the first few hundred milliseconds.
16
+ *
17
+ * The GPU string went with them despite being synchronous: creating a WebGL
18
+ * context to read it measured **10.8ms**, the most expensive single thing init
19
+ * did, and on a low-end phone that lands next to first paint. Nothing on the
20
+ * first request depends on it.
21
+ *
22
+ * What is left is only what is already sitting in memory, locale, timezone,
23
+ * colour scheme, screen and viewport, cores, memory, network type, all of it
24
+ * free to read, and all of it either attribution or the frame the visit
25
+ * happened in.
26
+ */
27
+ export declare const getUserProperties: () => {
2
28
  locale: string;
3
29
  timezone: string;
4
30
  dark_mode: boolean;
5
- private_mode: boolean;
6
31
  properties: Record<string, any>;
7
- }>;
32
+ };
@@ -0,0 +1,51 @@
1
+ import { ILevoAudience } from '../types/analytics';
2
+ import { IIdentityReferrer } from '../types/identity';
3
+ /** The server's WorkspaceIdSchema: exactly 8 uppercase alphanumerics. */
4
+ declare const SAFE_WORKSPACE: RegExp;
5
+ /**
6
+ * Whether a url can go on the wire. Mirrors the server's SafeUrlSchema:
7
+ * parseable, ≤2048, and not a javascript:/data:/vbscript: protocol.
8
+ */
9
+ declare const isSendableUrl: (value: string | undefined | null) => value is string;
10
+ /**
11
+ * Coerce the stored referrer into something the server's schema accepts.
12
+ * Empty hostname is valid (direct traffic); a malformed one is blanked rather
13
+ * than allowed to fail the batch.
14
+ */
15
+ declare const sanitizeReferrer: (referrer: IIdentityReferrer | null | undefined) => IIdentityReferrer | undefined;
16
+ /** Clamp the session-scoped traits. Mirrors SessionTraitsSchema. */
17
+ declare const sanitizeTraits: (traits: {
18
+ created_at: string;
19
+ darkMode: boolean | null | undefined;
20
+ landingUrl: string | null | undefined;
21
+ locale: string | null | undefined;
22
+ properties: ILevoAudience.Traits["properties"] | null | undefined;
23
+ referrer: IIdentityReferrer | null | undefined;
24
+ timezone: string | null | undefined;
25
+ }) => ILevoAudience.SessionTraits;
26
+ /**
27
+ * An event that has been through {@link sanitizeEvent}.
28
+ *
29
+ * A separate type rather than a cast back to the queued shape: sanitizing
30
+ * genuinely produces something different, because a field that fails the
31
+ * server's rules is DROPPED. Those fields are optional server-side, so the
32
+ * honest wire contract has them optional here too.
33
+ */
34
+ type ISanitizedEvent<T> = Omit<T, "hostname" | "identifier" | "page_title" | "pathname" | "resource" | "url"> & {
35
+ hostname?: string;
36
+ identifier?: string;
37
+ page_title?: string;
38
+ pathname?: string;
39
+ resource?: string;
40
+ url?: string;
41
+ };
42
+ /**
43
+ * Bring one queued event inside the server's own rules before it goes out.
44
+ *
45
+ * Dropped vs truncated is decided per field by what a wrong value costs. A url
46
+ * or hostname is an identity. Half of one attributes the event to a page or
47
+ * host nobody visited, so a failing value is omitted. A title or a path is
48
+ * descriptive, so truncating keeps most of the meaning.
49
+ */
50
+ declare const sanitizeEvent: <T extends Record<string, unknown>>(event: T) => ISanitizedEvent<T>;
51
+ export { type ISanitizedEvent, SAFE_WORKSPACE, isSendableUrl, sanitizeEvent, sanitizeReferrer, sanitizeTraits, };
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@levo-so/insights",
3
3
  "description": "Levo analytics",
4
- "version": "0.3.10",
4
+ "version": "0.3.11",
5
5
  "author": "Levo Engineering <devs@theinternetfolks.com>",
6
6
  "dependencies": {
7
7
  "@analytics/activity-utils": "0.2.2",
8
8
  "@analytics/visitor-source": "0.0.7",
9
+ "@theinternetfolks/snowflake": "1.3.0",
9
10
  "analytics": "0.8.14",
10
11
  "get-user-locale": "2.3.2",
11
12
  "lodash-es": "4.18.1",
@@ -16,8 +17,8 @@
16
17
  "typescript": "5.9.3",
17
18
  "vite": "8.0.8",
18
19
  "vite-plugin-dts": "4.5.4",
19
- "@levo/ts-config": "0.0.0",
20
- "@levo-so/core": "0.3.10"
20
+ "@levo-so/core": "0.3.11",
21
+ "@levo/ts-config": "0.0.0"
21
22
  },
22
23
  "exports": {
23
24
  ".": {
@@ -37,7 +38,7 @@
37
38
  "main": "./dist/index.js",
38
39
  "module": "./dist/index.js",
39
40
  "peerDependencies": {
40
- "@levo-so/core": "0.3.10"
41
+ "@levo-so/core": "0.3.11"
41
42
  },
42
43
  "publishConfig": {
43
44
  "access": "public"
@@ -47,9 +48,11 @@
47
48
  "types": "./dist/index.d.ts",
48
49
  "scripts": {
49
50
  "build": "vite build",
50
- "check-types": "tsc --noEmit",
51
+ "check-types": "tsc --noEmit && tsc --noEmit -p tsconfig.demo.json",
51
52
  "clean": "npx rimraf dist node_modules .turbo",
53
+ "demo": "vite --config vite.demo.config.ts",
52
54
  "dev": "vite build --watch",
53
- "lib-dev": "vite"
55
+ "lib-dev": "vite --config vite.demo.config.ts",
56
+ "test": "vite build && node --experimental-strip-types --test src/**/*.check.ts"
54
57
  }
55
58
  }