@lotics/app-sdk 0.77.3 → 0.78.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,7 +1 @@
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
1
  export declare function bootstrapAnalytics(): Promise<void>;
@@ -9,12 +9,20 @@
9
9
  * values — and Điều 3.2 of the service contract warrants that our telemetry
10
10
  * carries no record or document content. The flags below are what enforce
11
11
  * that; the project-level setting is NOT a backstop, since it is server-side
12
- * and fails open when the config fetch fails. The SDK emits explicit events
13
- * for genuine user actions only: `app_opened`, `app_file_uploaded`, and
14
- * `app_comment_*`. System signals data-read mechanics (a `useQuery` refetch)
15
- * and workflow/agent run outcomesare not events: the backend already logs
16
- * and persists every run (`workflow_executions`, `app_agent_runs`), so a client
17
- * event would be redundant system telemetry, not a gesture.
12
+ * and fails open when the config fetch fails.
13
+ *
14
+ * **What earns an event:** who KNOWS the fact. An effect the platform performs
15
+ * or persists is the platform's a comment, an upload, a workflow or agent run
16
+ * all leave rows, and a client copy would duplicate one AND keep arriving from
17
+ * apps pinned to an old SDK long after the code is deleted. A FAILURE is not a
18
+ * gesture either; it is system telemetry, and this bundle has no log sink to
19
+ * carry it, so it is not an event here by default.
20
+ *
21
+ * That leaves exactly one: `app_opened`. Apps are a separate origin the
22
+ * product's own analytics cannot see, and the app-host gate log records gate
23
+ * DECISIONS rather than routine serves, so nothing else counts an app being
24
+ * used. Everything a run does is already in `app_agent_runs` — including a
25
+ * user's Stop, which stamps `cancel_requested_at`.
18
26
  *
19
27
  * Every event is tagged with app identity and rolls up under the existing
20
28
  * `organization` group; embedded apps `identify` the member the host passes
@@ -33,34 +41,6 @@ import { hasMockFlag } from "./mock.js";
33
41
  const POSTHOG_KEY = "phc_N1nyqSRdo9XMK3DODxrxX2Y9jG3dppybruOuMznbz62";
34
42
  const POSTHOG_HOST = "https://us.i.posthog.com";
35
43
  const APP_HOST_SUFFIX = ".lotics.app";
36
- let loaded = false;
37
- let disabled = false;
38
- /**
39
- * Events fired before init completes (a hook can settle during the `context`
40
- * round-trip). Drained on init; capped so a never-initializing session can't
41
- * grow it unbounded.
42
- */
43
- const preInitQueue = [];
44
- const MAX_QUEUE = 100;
45
- function disable() {
46
- disabled = true;
47
- preInitQueue.length = 0;
48
- }
49
- /**
50
- * Capture an app event. Buffers until PostHog is ready, then emits directly;
51
- * no-ops permanently once tracking is known to be off, so the hooks can call it
52
- * unconditionally.
53
- */
54
- export function captureAppEvent(event, props) {
55
- if (disabled)
56
- return;
57
- if (loaded) {
58
- posthog.capture(event, props);
59
- return;
60
- }
61
- if (preInitQueue.length < MAX_QUEUE)
62
- preInitQueue.push({ event, props });
63
- }
64
44
  function onDeployedAppHost() {
65
45
  try {
66
46
  return window.location.hostname.endsWith(APP_HOST_SUFFIX);
@@ -73,17 +53,14 @@ export async function bootstrapAnalytics() {
73
53
  // Only the deployed app host tracks: a design-time/screenshot load
74
54
  // (?__mock=1) and `lotics app dev` (localhost) emit nothing. This replaces
75
55
  // the old "no key off-prod" gate now that the key is hardcoded.
76
- if (hasMockFlag() || !onDeployedAppHost()) {
77
- disable();
56
+ if (hasMockFlag() || !onDeployedAppHost())
78
57
  return;
79
- }
80
58
  let ctx;
81
59
  try {
82
60
  ctx = await rpc("context", {});
83
61
  }
84
62
  catch {
85
63
  // Best-effort: a context-resolution failure must never break the app.
86
- disable();
87
64
  return;
88
65
  }
89
66
  posthog.init(POSTHOG_KEY, {
@@ -113,9 +90,12 @@ export async function bootstrapAnalytics() {
113
90
  // Tag + capture from `loaded` (once PostHog has initialized) — the robust
114
91
  // point to register super-properties and emit the first event.
115
92
  loaded: (ph) => {
93
+ // Ids only. A super-property rides every event and every `$exception`
94
+ // the session sends, and an app's NAME is the customer's own words —
95
+ // routinely a counterparty — which Điều 3.2 keeps out of telemetry. The
96
+ // id resolves to the name for anyone entitled to it.
116
97
  ph.register({
117
98
  app_id: ctx.app_id,
118
- app_name: ctx.app_name,
119
99
  workspace_id: ctx.workspace_id,
120
100
  organization_id: ctx.organization_id,
121
101
  });
@@ -127,10 +107,7 @@ export async function bootstrapAnalytics() {
127
107
  // visitors stay anonymous (member_id null).
128
108
  if (ctx.member_id)
129
109
  ph.identify(ctx.member_id);
130
- loaded = true;
131
110
  ph.capture("app_opened");
132
- for (const e of preInitQueue.splice(0))
133
- ph.capture(e.event, e.props);
134
111
  },
135
112
  });
136
113
  }
@@ -25,7 +25,6 @@
25
25
  import { useCallback } from "react";
26
26
  import useSWR from "swr";
27
27
  import { rpc } from "./rpc.js";
28
- import { captureAppEvent } from "./analytics.js";
29
28
  import { useAppContext } from "./viewer.js";
30
29
  /** The reduced storage shape the update endpoint accepts for `files`. */
31
30
  function toStorageFiles(files) {
@@ -101,7 +100,6 @@ export function useComments(args) {
101
100
  revalidate: false,
102
101
  populateCache: true,
103
102
  });
104
- captureAppEvent("app_comment_created", { has_files: fileIds.length > 0 });
105
103
  }, [available, memberId, record_id, swr]);
106
104
  const updateComment = useCallback(async (id, input) => {
107
105
  if (!available)
@@ -129,7 +127,6 @@ export function useComments(args) {
129
127
  revalidate: false,
130
128
  populateCache: true,
131
129
  });
132
- captureAppEvent("app_comment_updated", {});
133
130
  }, [available, record_id, swr]);
134
131
  const deleteComment = useCallback(async (id) => {
135
132
  if (!available)
@@ -145,7 +142,6 @@ export function useComments(args) {
145
142
  revalidate: false,
146
143
  populateCache: true,
147
144
  });
148
- captureAppEvent("app_comment_deleted", {});
149
145
  }, [available, record_id, swr]);
150
146
  return {
151
147
  comments,
package/dist/src/hooks.js CHANGED
@@ -22,7 +22,6 @@ import useSWRInfinite from "swr/infinite";
22
22
  import { rpc, rpcAgentRun, rpcAgentRunContinue, postHostNotification, subscribeHostRefetch, } from "./rpc.js";
23
23
  import { initialAgentRunState, reduceAgentChunk, parseSseChunks, adoptSettledRun, pendingInteractiveCall, buildChoiceOutput, applyInteractiveAnswer, landingOf, proseOf, ABORTED, } from "./agent_stream.js";
24
24
  import { getMockRows, hasMockFlag } from "./mock.js";
25
- import { captureAppEvent } from "./analytics.js";
26
25
  export { buildChoiceOutput } from "./agent_stream.js";
27
26
  export function useWorkflow(alias) {
28
27
  return useCallback((inputs) => rpc("workflow", { alias, inputs: inputs ?? {} }), [alias]);
@@ -288,7 +287,6 @@ export function useFileUpload() {
288
287
  setError(null);
289
288
  try {
290
289
  const uploaded = await rpc("upload", { file, fidelity });
291
- captureAppEvent("app_file_uploaded", { mime_type: file.type, fidelity });
292
290
  return uploaded;
293
291
  }
294
292
  catch (err) {
@@ -601,14 +599,6 @@ export function useAgentRun(alias) {
601
599
  error: "The run was interrupted and its result could not be confirmed. Run it again.",
602
600
  };
603
601
  safeSetState(acc);
604
- // Fleet visibility: how often edges cut agent SSE streams. One
605
- // event per truncation, with whether the row rescued the result.
606
- captureAppEvent("app_agent_stream_truncated", {
607
- kind: "clean_end",
608
- recovered: settled != null,
609
- settled_status: settled?.status ?? null,
610
- has_output: acc.output !== undefined,
611
- });
612
602
  }
613
603
  return landingOf(acc);
614
604
  })
@@ -635,16 +625,6 @@ export function useAgentRun(alias) {
635
625
  acc = adoptGuarded(acc, settled);
636
626
  safeSetState(acc);
637
627
  }
638
- // The same fleet-visibility beacon as the clean-end path — a dropped
639
- // connection is the OTHER way a stream dies mid-run, and it was
640
- // previously invisible (the beacon fired only on clean-end
641
- // truncations, undercounting every network-error cut).
642
- captureAppEvent("app_agent_stream_truncated", {
643
- kind: "connection_error",
644
- recovered: settled != null,
645
- settled_status: settled?.status ?? null,
646
- has_output: acc.output !== undefined,
647
- });
648
628
  if (settled)
649
629
  return landingOf(acc);
650
630
  }
@@ -663,7 +643,6 @@ export function useAgentRun(alias) {
663
643
  // in-flight promise makes a double-click resolve with the first run's
664
644
  // result; `replace: true` is the explicit opt-in to abort-and-restart.
665
645
  if (inflightRef.current && !opts.replace) {
666
- captureAppEvent("app_agent_run_deduped", { alias });
667
646
  return inflightRef.current;
668
647
  }
669
648
  handleRef.current?.abort();
@@ -710,6 +689,10 @@ export function useAgentRun(alias) {
710
689
  const abort = useCallback(() => handleRef.current?.abort(), []);
711
690
  const cancel = useCallback(() => {
712
691
  // Stop server-side too (saves tokens on an unwanted run), then locally.
692
+ // No analytics event here: the cancel RPC stamps `app_agent_runs
693
+ // .cancel_requested_at`, which is what separates a user's Stop from the
694
+ // unmount/tab-close that `abort` also serves — a client event would only
695
+ // duplicate a column, and would go stale the moment an app pins an old SDK.
713
696
  const runId = runIdRef.current;
714
697
  if (runId)
715
698
  void rpc("agentRun.cancel", { run_id: runId }).catch(() => { });
package/docs/ai.md CHANGED
@@ -57,8 +57,8 @@ await recognize.run({ image_file_id: fileId }, { sessionId });
57
57
 
58
58
  | Member | Type | What it is |
59
59
  |---|---|---|
60
- | `run` | `(input, { sessionId, replace? }) => Promise<AgentRunLanding<TOutput>>` | Start a run. Streams progress into the hook's state and resolves with **how the leg ended** — `{kind:"settled", output?, text}`, `{kind:"parked"}`, `{kind:"failed", error}` or `{kind:"aborted"}`. Switch on `kind`; do NOT read the hook's state to tell them apart, because it has not committed when the promise resolves (that race closed dialogs over live, answerable runs). A run failure is DATA here, so no try/catch is needed for it. **Single-flight:** while a run is in flight, calling it again returns the in-flight run's promise — an accidental double-press joins the first run instead of billing a second one (each dedup emits the `app_agent_run_deduped` analytics event). Pass `replace: true` to deliberately abort-and-restart; the replaced run still executes and bills server-side |
61
- | `cancel` | `() => void` | Stop the run **server-side** (saves tokens) and locally. Wire a user-facing Stop button to this |
60
+ | `run` | `(input, { sessionId, replace? }) => Promise<AgentRunLanding<TOutput>>` | Start a run. Streams progress into the hook's state and resolves with **how the leg ended** — `{kind:"settled", output?, text}`, `{kind:"parked"}`, `{kind:"failed", error}` or `{kind:"aborted"}`. Switch on `kind`; do NOT read the hook's state to tell them apart, because it has not committed when the promise resolves (that race closed dialogs over live, answerable runs). A run failure is DATA here, so no try/catch is needed for it. **Single-flight:** while a run is in flight, calling it again returns the in-flight run's promise — an accidental double-press joins the first run instead of billing a second one. Pass `replace: true` to deliberately abort-and-restart; the replaced run still executes and bills server-side |
61
+ | `cancel` | `() => void` | Stop the run **server-side** (saves tokens) and locally. Wire a user-facing Stop button to this. The platform stamps `app_agent_runs.cancel_requested_at`, which is what distinguishes a user Stop from the unmount that `abort` also serves — so there is no client analytics event for it |
62
62
  | `abort` | `() => void` | Stop listening **locally only** — the run keeps executing server-side and its result is still persisted. This is the unmount path (the hook calls it automatically on unmount) |
63
63
  | `status` | `"idle" \| "streaming" \| "awaiting_input" \| "completed" \| "error"` | Whole-run state. `awaiting_input` = the run is PARKED on a question the agent asked (see the ask-back section below). `abort`/`cancel` reset it to `"idle"` (and clear the partial transcript) |
64
64
  | `pendingChoice` | `PendingChoice \| null` | The agent's pending question(s) — non-null exactly while `status` is `awaiting_input`. `questions` maps 1:1 onto `@lotics/ui`'s `ClarifyWizard` (`{question, options: {label, description}[], allow_custom}`) |
@@ -184,7 +184,7 @@ The run's lifetime is decoupled from the stream: the server drives it to complet
184
184
  - **The connection drops with an error** → the hook polls the persisted run to completion (every 2.5 s) and resolves with the settled result instead of surfacing a network error.
185
185
  - **The stream ends cleanly WITHOUT a `finish` frame** — an edge/proxy can close a long SSE gracefully mid-run, which looks like completion but swallowed the trailing frames (including the structured result). The hook detects the missing `finish` and polls the row the same way.
186
186
 
187
- Both truncation shapes emit the `app_agent_stream_truncated` analytics event (`kind: "connection_error" | "clean_end"`, with whether the row rescued the result), so edge-cut frequency is visible fleet-wide.
187
+ Either way the run itself is unaffected it keeps executing server-side and settles in `app_agent_runs`, which is where its outcome is read.
188
188
 
189
189
  **Anonymous runs poll with a capability token, handled for you.** On a publicly-shared app a visitor has no member identity, so the server cannot authorize their poll by ownership — `triggered_by_member_id` is null by design. Instead the run response carries a per-run token (`x-app-agent-run-token`); the SDK stores it against that run id and replays it on the poll, on `cancel`, and on `answerChoice`. It is transport-level, like the password-session token — never surfaced to app code, and never issued for a member run (identity already authorizes those). Nothing to wire: an app calls `useAgentRun` the same way on both. The token is bound to ONE run, so it cannot read another visitor's, and it is why a dropped connection on a public app recovers rather than losing the answer.
190
190
 
@@ -407,15 +407,30 @@ pieces. Compose these — don't hand-roll search:
407
407
  search constraint and would dump the table on first paint. `enabled` makes "nothing loads until
408
408
  you type" true. Add `revalidateOnFocus: false` — re-running an ephemeral search on refocus is
409
409
  wasted work.
410
+ - **Debounce the term — `enabled` is not a substitute.** `enabled` decides *whether* to ask, not
411
+ *how often*: wire an input's own state into `params` and every keystroke past the first is a
412
+ fresh cache key and a fresh request. A six-letter name costs six, and on a list screen each one
413
+ is several — `usePaginatedQuery` re-counts whenever `params` change, and any sibling query
414
+ taking the same term goes with it. Keep the input's value in one state and debounce the COMMIT
415
+ into a second (`useDebouncedCallback` from `@lotics/ui/use_debounced_callback`, ~250 ms). The
416
+ two values are genuinely different — what is being typed, and what the rows on screen answer —
417
+ and anything reporting on the results (an empty state, a count, a "showing N for X" line) reads
418
+ the committed one, or it describes a set the server was never asked for. `Combobox` already
419
+ debounces its own `onSearchChange`; this is for a search box you built yourself.
410
420
  - **`useRecents(key, { max })`** — persist the picked option locally; pass its list as
411
421
  `recentOptions` ([./navigation_and_state.md](./navigation_and_state.md)).
412
422
 
413
423
  ```tsx
414
- const [term, setTerm] = useState(""); // live input stays local
424
+ const [typed, setTyped] = useState(""); // the input's own value, every keystroke
425
+ const [term, setTerm] = useState(""); // what the server is asked, once typing settles
426
+ const commit = useDebouncedCallback(setTerm, 250);
427
+
428
+ <SearchInput value={typed} onChangeText={(v) => { setTyped(v); commit(v.trim()); }} />;
429
+
415
430
  const { rows, loading } = useQuery(
416
431
  "searchCustomers",
417
432
  { q: term },
418
- { enabled: term.trim().length > 0, revalidateOnFocus: false },
433
+ { enabled: term.length > 0, revalidateOnFocus: false },
419
434
  );
420
435
  ```
421
436
 
package/docs/runtime.md CHANGED
@@ -344,37 +344,29 @@ await checkIn({ latitude: r.coords.latitude, longitude: r.coords.longitude });
344
344
  ## Automatic analytics (PostHog)
345
345
 
346
346
  `mount()` boots a PostHog instance per app — apps are a separate cross-origin
347
- bundle, invisible to the product's own analytics. **No per-app wiring**: don't
348
- install `posthog-js` or call any analytics API from app code.
349
-
350
- - **Explicit events only — user gestures.** Autocapture, pageviews, and session
351
- replay are off (autocapture is disabled at the project level — a client flag
352
- could not re-enable it). System signals are deliberately not events: data
353
- reads (`useQuery` fetches) and workflow/agent run outcomes. The backend
354
- already logs and persists every run (`workflow_executions`, `app_agent_runs`).
355
- - Events the SDK emits automatically:
356
-
357
- | Event | Fired when | Properties |
358
- |---|---|---|
359
- | `app_opened` | analytics finished initializing after `mount()` | — |
360
- | `app_file_uploaded` | a `useFileUpload` upload succeeds | `mime_type` |
361
- | `app_comment_created` | a comment is created via `useComments` | `has_files` |
362
- | `app_comment_updated` / `app_comment_deleted` | comment edit/delete succeeds | — |
363
-
364
- - Every event carries the app identity as super-properties (`app_id`,
365
- `app_name`, `workspace_id`, `organization_id`) and rolls up under the
366
- `organization` group. Embedded apps `identify` the signed-in member — app and
347
+ bundle, invisible to the product's own analytics. It is entirely automatic:
348
+ don't install `posthog-js` or call any analytics API from app code, and there is
349
+ no hook for app-defined events. If a bespoke funnel matters, request the surface
350
+ as a platform change.
351
+
352
+ The SDK emits one event, `app_opened`, once PostHog finishes initializing.
353
+ Everything an app *does* is already recorded by the platform — a comment, an
354
+ upload, a workflow or agent run each leave a row, and a run's outcome, including
355
+ a user's Stop (`cancel_requested_at`), lands in `app_agent_runs`.
356
+
357
+ - Autocapture, pageviews, dead clicks, and session replay are all off, set at
358
+ init rather than relied on project-wide: the server-side setting fails open,
359
+ and an app screen renders the customer's own records.
360
+ - `app_opened` carries the app identity as super-properties `app_id`,
361
+ `workspace_id`, `organization_id`, ids only, since a name is the customer's
362
+ own words — and rolls up under the `organization` group. Embedded apps `identify` the signed-in member app and
367
363
  product events share one person; standalone visitors stay anonymous.
368
364
  - Uncaught exceptions are captured (PostHog error tracking) in addition to
369
- `mount()`'s visible banner.
365
+ `mount()`'s visible banner — the app's one error channel.
370
366
  - **Tracking is gated to the deployed app host** (`*.lotics.app`). `lotics app
371
367
  dev` (localhost) and any `?__mock=1` load emit nothing. Best-effort
372
- throughout: a failed init or a failed `context` resolution disables tracking
373
- and never breaks the app. Events fired before init completes are buffered
374
- (bounded) and drained on init.
375
- - **Limitation:** there is no public API for custom app events — the capture
376
- function is internal to the SDK. If a bespoke funnel matters, request the
377
- surface as a platform change.
368
+ throughout: a failed init or a failed `context` resolution leaves the app
369
+ fully working and untracked.
378
370
  - **Limitation:** PostHog's default bot/user-agent filter applies — headless
379
371
  browsers (e.g. Playwright) are never tracked, so analytics cannot be verified
380
372
  through headless automation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.77.3",
3
+ "version": "0.78.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": {