@lotics/app-sdk 0.15.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.
@@ -1 +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;
1
7
  export declare function bootstrapAnalytics(): Promise<void>;
@@ -1,75 +1,128 @@
1
1
  /**
2
2
  * PostHog analytics for custom-code apps.
3
3
  *
4
- * Apps run as a separate cross-origin bundle at `<slug>.lotics.app`, so the
5
- * product's PostHog instance (on `app.lotics.ai`) can't see them every app
6
- * was previously dark. `bootstrapAnalytics` runs once from `mount()` and gives
7
- * every app tracking for free, with no per-app wiring: PostHog **autocapture**
8
- * (clicks, pageview, pageleave) records the user actions, plus exception
9
- * capture for error visibility. Every event is tagged with app identity so it's
10
- * attributable per app/workspace/org and rolls up under the existing
11
- * `organization` group; embedded apps `identify` the authenticated member the
12
- * host passes down, so app + product events share one person. See `rpc.ts`
13
- * `AppContext` for how identity is resolved per transport.
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.
14
6
  *
15
- * Autocapture is the only event source. RPC/data lifecycle (query / workflow /
16
- * upload) is a system signal, not a user action — and the interaction that
17
- * triggers each is already autocaptured so the SDK emits no custom events.
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.
18
15
  *
19
- * Best-effort: any failure (offline, an un-updated host that doesn't know the
20
- * `context` op, tracking disabled for the environment) leaves the app fully
21
- * functional and silently untracked.
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.
22
26
  */
23
27
  import posthog from "posthog-js";
24
28
  import { rpc } from "./rpc.js";
25
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
+ }
26
69
  export async function bootstrapAnalytics() {
27
- // A design-time / screenshot load (?__mock=1) is never real usage.
28
- if (hasMockFlag())
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();
29
75
  return;
76
+ }
30
77
  let ctx;
31
78
  try {
32
79
  ctx = await rpc("context", {});
33
80
  }
34
81
  catch {
35
- // Best-effort: a context-resolution failure (offline, an un-updated host
36
- // that doesn't know the op) must never break the app.
82
+ // Best-effort: a context-resolution failure must never break the app.
83
+ disable();
37
84
  return;
38
85
  }
39
- // No key → tracking disabled for this environment (dev/preview/test, or a
40
- // host running in a non-production build). Mirrors the frontend's prod gate.
41
- if (!ctx.posthog_key)
42
- return;
43
- posthog.init(ctx.posthog_key, {
44
- api_host: ctx.posthog_host,
45
- autocapture: true,
46
- capture_pageview: true,
47
- capture_pageleave: true,
48
- // Error tracking for the app: this is the app bundle's only exception
49
- // channel (mount() renders a local banner but never posts), and we run
50
- // customer apps their crashes are ours to see.
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.
51
98
  capture_exceptions: true,
52
- // Session replay is on by default at the project level (docs/security.md
53
- // §11.4), but apps were never scoped for it and it would silently record
54
- // anonymous public-app visitors. Keep it off on this surface.
99
+ // Session replay is on by default project-wide; never on the app surface.
55
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
+ },
56
127
  });
57
- // Tag every event (autocapture included) so app traffic is attributable and
58
- // filterable — any event carrying `app_id` is an app event. `app_name` spares
59
- // an id lookup when debugging; the org's name lives on the `organization`
60
- // group, not denormalized here.
61
- posthog.register({
62
- app_id: ctx.app_id,
63
- app_name: ctx.app_name,
64
- workspace_id: ctx.workspace_id,
65
- organization_id: ctx.organization_id,
66
- });
67
- // Guarded: a bridged host that answered `context` before its auth member
68
- // loaded sends an empty org — never group on an empty key.
69
- if (ctx.organization_id)
70
- posthog.group("organization", ctx.organization_id);
71
- // Embedded apps attach to the same person as the product; standalone
72
- // visitors stay anonymous (member_id null).
73
- if (ctx.member_id)
74
- posthog.identify(ctx.member_id);
75
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";
package/dist/src/mount.js CHANGED
@@ -14,10 +14,10 @@ export function mount(element, options = {}) {
14
14
  // *something* even before the parent's debug telemetry is wired up.
15
15
  installVisibleErrorHandlers(container);
16
16
  createRoot(container).render(element);
17
- // Fire-and-forget: resolve the app's identity + analytics config and start
18
- // PostHog autocapture. Never awaited — analytics must not delay first paint,
19
- // and is best-effort so its failure can't break the app. No-ops in mock mode
20
- // and when tracking is disabled for the environment.
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
21
  void bootstrapAnalytics();
22
22
  }
23
23
  function installVisibleErrorHandlers(container) {
package/dist/src/rpc.d.ts CHANGED
@@ -20,17 +20,17 @@
20
20
  */
21
21
  export type RpcOp = "query" | "workflow" | "upload" | "context";
22
22
  /**
23
- * The app's identity + analytics config, resolved once at startup to bootstrap
24
- * PostHog. Assembled by whichever transport is active:
23
+ * The app's identity, resolved once at startup to tag PostHog events.
24
+ * Assembled by whichever transport is active:
25
25
  *
26
26
  * - **Bridged** — the host (authenticated) supplies `member_id` so app events
27
27
  * attach to the same PostHog person as the product, plus org/workspace/app
28
- * from its own context and the PostHog key from its env.
29
- * - **Standalone** — the public `/by-subdomain` endpoint returns identity +
30
- * config; `member_id` is null (anonymous visitor).
28
+ * from its own context.
29
+ * - **Standalone** — the public `/by-subdomain` endpoint returns identity;
30
+ * `member_id` is null (anonymous visitor).
31
31
  *
32
- * `posthog_key` is null when tracking is disabled for the environment
33
- * (dev/preview/test, or a non-production host build) — the SDK then no-ops.
32
+ * The PostHog key/host are not here they're the public project key, hardcoded
33
+ * in `analytics.ts`.
34
34
  */
35
35
  export interface AppContext {
36
36
  app_id: string;
@@ -38,7 +38,5 @@ export interface AppContext {
38
38
  workspace_id: string;
39
39
  organization_id: string;
40
40
  member_id: string | null;
41
- posthog_key: string | null;
42
- posthog_host: string;
43
41
  }
44
42
  export declare function rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T>;
package/dist/src/rpc.js CHANGED
@@ -230,8 +230,6 @@ async function standaloneContext() {
230
230
  organization_id: info.organization_id,
231
231
  // No host session in standalone mode — the visitor is anonymous.
232
232
  member_id: null,
233
- posthog_key: info.posthog_key,
234
- posthog_host: info.posthog_host,
235
233
  };
236
234
  }
237
235
  async function standaloneQuery(p) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.15.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": {