@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.
- package/dist/src/analytics.d.ts +7 -0
- package/dist/src/analytics.js +128 -0
- package/dist/src/hooks.js +15 -2
- package/dist/src/mock.d.ts +8 -0
- package/dist/src/mock.js +14 -7
- package/dist/src/mount.d.ts +2 -2
- package/dist/src/mount.js +6 -0
- package/dist/src/rpc.d.ts +21 -1
- package/dist/src/rpc.js +40 -9
- package/package.json +8 -5
|
@@ -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) =>
|
|
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/mock.d.ts
CHANGED
|
@@ -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
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
|
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
|
package/dist/src/mount.d.ts
CHANGED
|
@@ -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),
|
|
30
|
-
*
|
|
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`.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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 =
|
|
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.
|
|
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.
|
|
22
|
-
"react-dom": "^19.
|
|
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.
|
|
28
|
-
"react-dom": "^19.
|
|
30
|
+
"react": "^19.2.0",
|
|
31
|
+
"react-dom": "^19.2.0"
|
|
29
32
|
},
|
|
30
33
|
"keywords": [
|
|
31
34
|
"lotics",
|