@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.
- package/dist/src/analytics.d.ts +6 -0
- package/dist/src/analytics.js +106 -53
- package/dist/src/hooks.js +15 -2
- package/dist/src/mount.js +4 -4
- package/dist/src/rpc.d.ts +7 -9
- package/dist/src/rpc.js +0 -2
- package/package.json +1 -1
package/dist/src/analytics.d.ts
CHANGED
|
@@ -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>;
|
package/dist/src/analytics.js
CHANGED
|
@@ -1,75 +1,128 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PostHog analytics for custom-code apps.
|
|
3
3
|
*
|
|
4
|
-
* Apps
|
|
5
|
-
* product's PostHog
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
20
|
-
* `
|
|
21
|
-
*
|
|
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
|
-
//
|
|
28
|
-
|
|
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
|
|
36
|
-
|
|
82
|
+
// Best-effort: a context-resolution failure must never break the app.
|
|
83
|
+
disable();
|
|
37
84
|
return;
|
|
38
85
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
//
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
18
|
-
//
|
|
19
|
-
// and is best-effort so its failure can't break
|
|
20
|
-
//
|
|
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
|
|
24
|
-
*
|
|
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
|
|
29
|
-
* - **Standalone** — the public `/by-subdomain` endpoint returns identity
|
|
30
|
-
*
|
|
28
|
+
* from its own context.
|
|
29
|
+
* - **Standalone** — the public `/by-subdomain` endpoint returns identity;
|
|
30
|
+
* `member_id` is null (anonymous visitor).
|
|
31
31
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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) {
|