@lotics/app-sdk 0.13.0 → 0.16.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,7 @@
1
+ /**
2
+ * Capture an app event. Buffers until PostHog is ready, then emits directly;
3
+ * no-ops permanently once tracking is known to be off, so the hooks can call it
4
+ * unconditionally.
5
+ */
6
+ export declare function captureAppEvent(event: string, props?: Record<string, unknown>): void;
7
+ export declare function bootstrapAnalytics(): Promise<void>;
@@ -0,0 +1,128 @@
1
+ /**
2
+ * PostHog analytics for custom-code apps.
3
+ *
4
+ * Apps are a separate cross-origin bundle at `<slug>.lotics.app`, invisible to
5
+ * the product's PostHog — so `mount()` boots a PostHog instance per app.
6
+ *
7
+ * **Why explicit events, not autocapture:** the Lotics PostHog project runs
8
+ * with autocapture OFF (the product captures explicit events, not DOM
9
+ * autocapture), and that project setting overrides any client `autocapture`
10
+ * flag — so apps can't rely on autocapture. The SDK instead emits explicit
11
+ * events for genuine user actions: `app_opened`, plus `app_workflow_run` /
12
+ * `app_file_uploaded` from the hooks. Data-read mechanics (a `useQuery`
13
+ * refetch) are a system signal, not a user action, and are deliberately not
14
+ * events.
15
+ *
16
+ * Every event is tagged with app identity and rolls up under the existing
17
+ * `organization` group; embedded apps `identify` the member the host passes
18
+ * down so app + product events share one person. Exception capture is on (the
19
+ * project opts in); session replay is off (on by default project-wide, but
20
+ * apps — including anonymous public visitors — were never scoped for it).
21
+ *
22
+ * The key is the public, write-only project key (already shipped in every
23
+ * browser), hardcoded here. Tracking is gated to the deployed app host
24
+ * (`*.lotics.app`); `lotics app dev` runs on localhost and stays untracked.
25
+ * Best-effort: any failure leaves the app fully functional and untracked.
26
+ */
27
+ import posthog from "posthog-js";
28
+ import { rpc } from "./rpc.js";
29
+ import { hasMockFlag } from "./mock.js";
30
+ const POSTHOG_KEY = "phc_N1nyqSRdo9XMK3DODxrxX2Y9jG3dppybruOuMznbz62";
31
+ const POSTHOG_HOST = "https://us.i.posthog.com";
32
+ const APP_HOST_SUFFIX = ".lotics.app";
33
+ let loaded = false;
34
+ let disabled = false;
35
+ /**
36
+ * Events fired before init completes (a hook can settle during the `context`
37
+ * round-trip). Drained on init; capped so a never-initializing session can't
38
+ * grow it unbounded.
39
+ */
40
+ const preInitQueue = [];
41
+ const MAX_QUEUE = 100;
42
+ function disable() {
43
+ disabled = true;
44
+ preInitQueue.length = 0;
45
+ }
46
+ /**
47
+ * Capture an app event. Buffers until PostHog is ready, then emits directly;
48
+ * no-ops permanently once tracking is known to be off, so the hooks can call it
49
+ * unconditionally.
50
+ */
51
+ export function captureAppEvent(event, props) {
52
+ if (disabled)
53
+ return;
54
+ if (loaded) {
55
+ posthog.capture(event, props);
56
+ return;
57
+ }
58
+ if (preInitQueue.length < MAX_QUEUE)
59
+ preInitQueue.push({ event, props });
60
+ }
61
+ function onDeployedAppHost() {
62
+ try {
63
+ return window.location.hostname.endsWith(APP_HOST_SUFFIX);
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ export async function bootstrapAnalytics() {
70
+ // Only the deployed app host tracks: a design-time/screenshot load
71
+ // (?__mock=1) and `lotics app dev` (localhost) emit nothing. This replaces
72
+ // the old "no key off-prod" gate now that the key is hardcoded.
73
+ if (hasMockFlag() || !onDeployedAppHost()) {
74
+ disable();
75
+ return;
76
+ }
77
+ let ctx;
78
+ try {
79
+ ctx = await rpc("context", {});
80
+ }
81
+ catch {
82
+ // Best-effort: a context-resolution failure must never break the app.
83
+ disable();
84
+ return;
85
+ }
86
+ posthog.init(POSTHOG_KEY, {
87
+ // Pin the modern posthog-js init contract (the product uses the same).
88
+ defaults: "2025-05-24",
89
+ api_host: POSTHOG_HOST,
90
+ // Autocapture is disabled project-wide (the project setting overrides this
91
+ // flag), so app analytics is explicit events — not DOM autocapture or
92
+ // pageviews.
93
+ autocapture: false,
94
+ capture_pageview: false,
95
+ capture_pageleave: false,
96
+ // Error tracking (the project opts in). mount() only renders a local
97
+ // banner, so this is the app's one exception channel.
98
+ capture_exceptions: true,
99
+ // Session replay is on by default project-wide; never on the app surface.
100
+ disable_session_recording: true,
101
+ // posthog-js keeps its default bot/user-agent filter, so headless/bot
102
+ // traffic is never tracked (real users are unaffected). One consequence:
103
+ // analytics can't be verified through headless Playwright — it's filtered.
104
+ //
105
+ // Tag + capture from `loaded` (once PostHog has initialized) — the robust
106
+ // point to register super-properties and emit the first event.
107
+ loaded: (ph) => {
108
+ ph.register({
109
+ app_id: ctx.app_id,
110
+ app_name: ctx.app_name,
111
+ workspace_id: ctx.workspace_id,
112
+ organization_id: ctx.organization_id,
113
+ });
114
+ // Guarded: a bridged host that answered `context` before its auth member
115
+ // loaded sends an empty org — never group on an empty key.
116
+ if (ctx.organization_id)
117
+ ph.group("organization", ctx.organization_id);
118
+ // Embedded apps attach to the same person as the product; standalone
119
+ // visitors stay anonymous (member_id null).
120
+ if (ctx.member_id)
121
+ ph.identify(ctx.member_id);
122
+ loaded = true;
123
+ ph.capture("app_opened");
124
+ for (const e of preInitQueue.splice(0))
125
+ ph.capture(e.event, e.props);
126
+ },
127
+ });
128
+ }
package/dist/src/hooks.js CHANGED
@@ -16,8 +16,19 @@
16
16
  import { useCallback, useEffect, useState } from "react";
17
17
  import { rpc } from "./rpc.js";
18
18
  import { getMockRows } from "./mock.js";
19
+ import { captureAppEvent } from "./analytics.js";
19
20
  export function useWorkflow(alias) {
20
- return useCallback((inputs) => rpc("workflow", { alias, inputs: inputs ?? {} }), [alias]);
21
+ return useCallback(async (inputs) => {
22
+ try {
23
+ const result = await rpc("workflow", { alias, inputs: inputs ?? {} });
24
+ captureAppEvent("app_workflow_run", { alias, ok: true });
25
+ return result;
26
+ }
27
+ catch (err) {
28
+ captureAppEvent("app_workflow_run", { alias, ok: false });
29
+ throw err;
30
+ }
31
+ }, [alias]);
21
32
  }
22
33
  export function useQuery(alias, params) {
23
34
  // Stringified params key — structurally-equal-but-new param objects don't
@@ -88,7 +99,9 @@ export function useFileUpload() {
88
99
  setInFlight((n) => n + 1);
89
100
  setError(null);
90
101
  try {
91
- return await rpc("upload", { file });
102
+ const uploaded = await rpc("upload", { file });
103
+ captureAppEvent("app_file_uploaded", { mime_type: file.type });
104
+ return uploaded;
92
105
  }
93
106
  catch (err) {
94
107
  const message = err instanceof Error ? err.message : "Upload failed";
@@ -36,6 +36,14 @@ export interface AppFixture {
36
36
  * for HMR.
37
37
  */
38
38
  export declare function registerMockFixture(fixture: AppFixture | undefined): void;
39
+ /**
40
+ * True iff the iframe URL carries the `__mock=1` activation flag. Throws never;
41
+ * a malformed URL or missing `window` (SSR / jsdom without location) silently
42
+ * returns false. Distinct from `isMockMode`: analytics keys off the raw flag
43
+ * (a design-time / screenshot load emits no events regardless of fixtures),
44
+ * while query mocking additionally requires a registered fixture.
45
+ */
46
+ export declare function hasMockFlag(): boolean;
39
47
  /**
40
48
  * Returns the fixture rows for an alias when mock mode is active *and* the
41
49
  * fixture has an entry for that alias. Otherwise null — the hook falls
package/dist/src/mock.js CHANGED
@@ -36,14 +36,13 @@ export function registerMockFixture(fixture) {
36
36
  registeredFixture = fixture;
37
37
  }
38
38
  /**
39
- * True iff the iframe URL carries the `__mock=1` activation flag AND a
40
- * fixture was registered. Safe to call before mount returns false when no
41
- * fixture exists. Throws never; a malformed URL or missing `window` (SSR /
42
- * jsdom without location) silently returns false.
39
+ * True iff the iframe URL carries the `__mock=1` activation flag. Throws never;
40
+ * a malformed URL or missing `window` (SSR / jsdom without location) silently
41
+ * returns false. Distinct from `isMockMode`: analytics keys off the raw flag
42
+ * (a design-time / screenshot load emits no events regardless of fixtures),
43
+ * while query mocking additionally requires a registered fixture.
43
44
  */
44
- function isMockMode() {
45
- if (!registeredFixture)
46
- return false;
45
+ export function hasMockFlag() {
47
46
  try {
48
47
  return new URLSearchParams(window.location.search).get("__mock") === "1";
49
48
  }
@@ -51,6 +50,14 @@ function isMockMode() {
51
50
  return false;
52
51
  }
53
52
  }
53
+ /**
54
+ * True iff the iframe URL carries the `__mock=1` activation flag AND a
55
+ * fixture was registered. Safe to call before mount — returns false when no
56
+ * fixture exists.
57
+ */
58
+ function isMockMode() {
59
+ return Boolean(registeredFixture) && hasMockFlag();
60
+ }
54
61
  /**
55
62
  * Returns the fixture rows for an alias when mock mode is active *and* the
56
63
  * fixture has an entry for that alias. Otherwise null — the hook falls
@@ -26,8 +26,8 @@
26
26
  *
27
27
  * `mount` wires up React 19's createRoot against `#root` in the iframe shell,
28
28
  * sets up window.onerror/unhandledrejection forwarding so runtime crashes
29
- * surface in the parent's debug pane, registers the fixture (if any), and
30
- * renders the user's tree.
29
+ * surface in the parent's debug pane, registers the fixture (if any), renders
30
+ * the user's tree, and bootstraps PostHog analytics (see `./analytics.ts`).
31
31
  *
32
32
  * If the bundler doesn't ship #root in the user's `index.html`, we create it
33
33
  * — Vite's default scaffold provides one, but defensive creation keeps the
package/dist/src/mount.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createRoot } from "react-dom/client";
2
2
  import { registerMockFixture } from "./mock.js";
3
+ import { bootstrapAnalytics } from "./analytics.js";
3
4
  export function mount(element, options = {}) {
4
5
  registerMockFixture(options.fixture);
5
6
  let container = document.getElementById("root");
@@ -13,6 +14,11 @@ export function mount(element, options = {}) {
13
14
  // *something* even before the parent's debug telemetry is wired up.
14
15
  installVisibleErrorHandlers(container);
15
16
  createRoot(container).render(element);
17
+ // Fire-and-forget: resolve the app's identity and start PostHog (explicit
18
+ // events — autocapture is disabled project-wide). Never awaited — analytics
19
+ // must not delay first paint, and is best-effort so its failure can't break
20
+ // the app. No-ops in mock mode and off the deployed app host.
21
+ void bootstrapAnalytics();
16
22
  }
17
23
  function installVisibleErrorHandlers(container) {
18
24
  const showError = (message) => {
package/dist/src/rpc.d.ts CHANGED
@@ -18,5 +18,25 @@
18
18
  * app → host: { id, op, payload }
19
19
  * host → app: { id, type: "result", data } | { id, type: "error", message }
20
20
  */
21
- export type RpcOp = "query" | "workflow" | "upload";
21
+ export type RpcOp = "query" | "workflow" | "upload" | "context";
22
+ /**
23
+ * The app's identity, resolved once at startup to tag PostHog events.
24
+ * Assembled by whichever transport is active:
25
+ *
26
+ * - **Bridged** — the host (authenticated) supplies `member_id` so app events
27
+ * attach to the same PostHog person as the product, plus org/workspace/app
28
+ * from its own context.
29
+ * - **Standalone** — the public `/by-subdomain` endpoint returns identity;
30
+ * `member_id` is null (anonymous visitor).
31
+ *
32
+ * The PostHog key/host are not here — they're the public project key, hardcoded
33
+ * in `analytics.ts`.
34
+ */
35
+ export interface AppContext {
36
+ app_id: string;
37
+ app_name: string;
38
+ workspace_id: string;
39
+ organization_id: string;
40
+ member_id: string | null;
41
+ }
22
42
  export declare function rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T>;
package/dist/src/rpc.js CHANGED
@@ -3,11 +3,10 @@ import { runUploadPipeline } from "./upload/pipeline.js";
3
3
  /**
4
4
  * The embedding Lotics host's origin — present iff the app is bridged.
5
5
  *
6
- * Lazy + memoized so the module's top level doesn't touch `window`. The SDK
7
- * gets imported by `frontend/lib/download.ts`, which Jest pulls in at module
8
- * evaluation time before its jsdom environment finishes setting up
9
- * `window.location` — eager reads crash every test suite that transitively
10
- * imports the SDK.
6
+ * Lazy + memoized so the module's top level doesn't touch `window`. A test
7
+ * runner can pull this module in at evaluation time before its jsdom
8
+ * environment finishes setting up `window.location` an eager read would
9
+ * crash every suite that transitively imports the SDK.
11
10
  *
12
11
  * `undefined` = not yet computed; `string | null` = computed result.
13
12
  */
@@ -69,6 +68,7 @@ const PASSWORD_REQUIRED_CODE = "PASSWORD_REQUIRED";
69
68
  * `/by-subdomain` fetch and at most one password prompt.
70
69
  */
71
70
  let bootPromise = null;
71
+ let appInfoPromise = null;
72
72
  let sessionToken = null;
73
73
  function sessionStorageKey(appId) {
74
74
  return `lotics_app_session:${appId}`;
@@ -109,12 +109,32 @@ function clearStoredSession(appId) {
109
109
  // ignore
110
110
  }
111
111
  }
112
+ /**
113
+ * Resolve the app's identity + analytics config from its own subdomain. Shared
114
+ * promise so the context bootstrap and the first data call coalesce into one
115
+ * `/by-subdomain` fetch. Does NOT touch the password gate — identity is needed
116
+ * for analytics regardless of whether the visitor has unlocked the data, so a
117
+ * password-gated app still resolves (and tracks) before the prompt.
118
+ */
119
+ function resolveAppInfo() {
120
+ if (appInfoPromise)
121
+ return appInfoPromise;
122
+ const slug = window.location.hostname.split(".")[0];
123
+ const attempt = apiCall("GET", `/v1/apps/by-subdomain/${encodeURIComponent(slug)}`);
124
+ // Don't cache a rejection — a transient failure on first load would brick
125
+ // every later data call. Clear the slot so the next call retries.
126
+ attempt.catch(() => {
127
+ if (appInfoPromise === attempt)
128
+ appInfoPromise = null;
129
+ });
130
+ appInfoPromise = attempt;
131
+ return attempt;
132
+ }
112
133
  async function boot() {
113
134
  if (bootPromise)
114
135
  return bootPromise;
115
- const slug = window.location.hostname.split(".")[0];
116
136
  const attempt = (async () => {
117
- const info = (await apiCall("GET", `/v1/apps/by-subdomain/${encodeURIComponent(slug)}`));
137
+ const info = await resolveAppInfo();
118
138
  if (info.requires_password) {
119
139
  const stored = readStoredSession(info.app_id);
120
140
  if (stored) {
@@ -126,8 +146,6 @@ async function boot() {
126
146
  }
127
147
  return info;
128
148
  })();
129
- // Don't cache a rejection — a transient failure on first load would brick
130
- // every later data call. Clear the slot so the next call retries.
131
149
  attempt.catch(() => {
132
150
  if (bootPromise === attempt)
133
151
  bootPromise = null;
@@ -199,8 +217,21 @@ function rpcStandalone(op, payload) {
199
217
  return standaloneWorkflow(payload);
200
218
  case "upload":
201
219
  return standaloneUpload(payload.file);
220
+ case "context":
221
+ return standaloneContext();
202
222
  }
203
223
  }
224
+ async function standaloneContext() {
225
+ const info = await resolveAppInfo();
226
+ return {
227
+ app_id: info.app_id,
228
+ app_name: info.app_name,
229
+ workspace_id: info.workspace_id,
230
+ organization_id: info.organization_id,
231
+ // No host session in standalone mode — the visitor is anonymous.
232
+ member_id: null,
233
+ };
234
+ }
204
235
  async function standaloneQuery(p) {
205
236
  const { app_id } = await boot();
206
237
  const r = (await apiCall("POST", `/v1/apps/${app_id}/query`, { alias: p.alias, params: p.params }, { appId: app_id }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.13.0",
3
+ "version": "0.16.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,15 +17,18 @@
17
17
  "test": "vitest run",
18
18
  "prepublishOnly": "npm run build"
19
19
  },
20
+ "dependencies": {
21
+ "posthog-js": "^1.352.0"
22
+ },
20
23
  "peerDependencies": {
21
- "react": "^19.0.0",
22
- "react-dom": "^19.0.0"
24
+ "react": "^19.2.0",
25
+ "react-dom": "^19.2.0"
23
26
  },
24
27
  "devDependencies": {
25
28
  "@types/react": "^19.0.0",
26
29
  "@types/react-dom": "^19.0.0",
27
- "react": "^19.0.0",
28
- "react-dom": "^19.0.0"
30
+ "react": "^19.2.0",
31
+ "react-dom": "^19.2.0"
29
32
  },
30
33
  "keywords": [
31
34
  "lotics",