Skip to content

Commit 657b913

Browse files
authored
Add anonymous metadata based product analytics for local and self-host (#1498)
* Add anonymous product analytics for local and self-host * Track artifact usage in local and self-host analytics * Drop integration slugs from analytics events * Document telemetry in TELEMETRY.md * Ship platform-node as a runtime dependency of analytics
1 parent 6a801f2 commit 657b913

41 files changed

Lines changed: 1569 additions & 44 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/heavy-planets-tell.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@executor-js/analytics": patch
3+
"@executor-js/sdk": patch
4+
"@executor-js/api": patch
5+
"@executor-js/host-selfhost": patch
6+
"executor": patch
7+
---
8+
9+
Add anonymous product analytics to the local daemon (CLI + desktop) and self-host: execution counts split by MCP/API plane, toolkit usage, integration add/remove, and artifact usage (created/viewed/updated/deleted, attributed to agent tools vs the console UI), filed under a persisted per-install anonymous id. Opt out with DO_NOT_TRACK or EXECUTOR_DISABLE_ANALYTICS.

TELEMETRY.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# Telemetry
2+
3+
Executor's local products (the CLI, the desktop app) and self-hosted
4+
deployments send a small set of anonymous usage events. This document is the
5+
complete account of what is sent, what is deliberately not sent, why the
6+
feature exists, and how to turn it off.
7+
8+
The hosted cloud product has its own browser-side analytics, disclosed
9+
separately in its terms; this document covers the software that runs on your
10+
machines.
11+
12+
## Why
13+
14+
Executor is used mostly outside our infrastructure. Without some signal from
15+
local and self-hosted installs, every product decision about them is a guess:
16+
we cannot tell whether a feature ships broken, whether anyone uses toolkits,
17+
whether executions fail at unusual rates after a release, or whether the
18+
product is growing anywhere except cloud.
19+
20+
The events exist to answer exactly one kind of question: **how are product
21+
features being used?** They are metadata about the product, not about you.
22+
Anything that would answer "what is this user doing" — which APIs you call,
23+
what your tools are named, what your code does — is out of scope by design,
24+
not by omission.
25+
26+
## What is sent
27+
28+
Each event carries its named properties plus, on every event: a random
29+
per-install id, the product surface (`cli`, `desktop`, or `selfhost`), the
30+
release channel, and the app version.
31+
32+
| Event | Properties | Meaning |
33+
| --------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
34+
| `execution_completed` | `ok`, `plane` (`mcp`/`api`), `toolkit` (boolean) | A code execution finished, split by whether an agent (MCP) or a human-facing API triggered it, and whether a toolkit-scoped endpoint served it. |
35+
| `integration_added` | `plugin_key` | An integration was added, by kind (`openapi`, `mcp`, `graphql`, ...). |
36+
| `integration_removed` | `plugin_key` | An integration was removed, by kind. |
37+
| `artifact_created` | `via` (`agent`/`ui`) | A generative-UI artifact was saved. |
38+
| `artifact_viewed` | `via` | An artifact was opened for its content. |
39+
| `artifact_updated` | `via` | An artifact was overwritten or renamed. |
40+
| `artifact_deleted` | `via` | An artifact was deleted. |
41+
42+
That table is exhaustive. The typed catalog the code compiles against is
43+
[`packages/core/analytics/src/events.ts`](packages/core/analytics/src/events.ts);
44+
an event that is not in that file cannot be sent.
45+
46+
## What is never sent
47+
48+
- No code, tool arguments, tool results, or error messages.
49+
- No secrets, tokens, or credentials.
50+
- No names you typed: no integration slugs, connection names, toolkit slugs,
51+
tool names, or artifact titles. The **kind** of integration (`openapi`,
52+
`mcp`, ...) is a product question; **which** service you connected it to is
53+
your business.
54+
- No identity: no emails, usernames, hostnames, IP-derived location, or org
55+
names. Events are marked so the analytics backend builds no person profile.
56+
57+
## The anonymous id
58+
59+
A random UUID is minted the first time the daemon or server starts and stored
60+
as `analytics-id` in the data directory (`~/.executor` for local installs, the
61+
configured data dir for self-host). It exists so that ten events from one
62+
install count as one install, not ten. It is not derived from your machine,
63+
account, or network, and deleting the file resets it. When telemetry is
64+
disabled the file is never created.
65+
66+
## Opting out
67+
68+
Set either environment variable to `1`, `true`, or `yes`:
69+
70+
- `DO_NOT_TRACK` — the [cross-tool convention](https://consoledonottrack.com),
71+
which Executor also honors for its other outbound calls (crash reporting,
72+
the integrations.sh catalog fetch).
73+
- `EXECUTOR_DISABLE_ANALYTICS` — telemetry only, if you want the catalog
74+
fetch and crash reporting to keep working.
75+
76+
Opting out is total: the analytics service becomes a no-op, nothing is
77+
buffered, nothing is sent, and no id file is written. The CLI's managed
78+
service (launchd/systemd) forwards both variables into the supervised
79+
daemon's environment, so an opted-out install stays opted out.
80+
81+
## Delivery mechanics
82+
83+
Events buffer in memory (bounded) and flush in batches on a fixed cadence,
84+
plus once at shutdown. Delivery is best-effort: a failed flush re-queues and
85+
retries later, failures are swallowed, and no user-facing operation ever
86+
waits on — or can be failed by — analytics. The ingest endpoint is PostHog
87+
(`us.i.posthog.com`); the project key in the source identifies the project
88+
and grants no read access.
89+
90+
## Where the line is enforced
91+
92+
Structurally, not by convention:
93+
94+
- The event catalog is a closed, typed interface — adding a property means
95+
editing [`events.ts`](packages/core/analytics/src/events.ts) in a reviewed
96+
change, next to the property rules at the top of that file.
97+
- Attribution (`plane`, `via`) is bound where each serving surface is
98+
composed, so events cannot carry request-controlled labels.
99+
- Observers hang off neutral seams (an engine wrapper, post-commit hooks); the
100+
core SDK carries no analytics vocabulary and hosts that do not opt in — like
101+
every test — compose with no analytics at all.

apps/cli/src/service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,10 @@ const serviceEnvironment = (
177177
"EXECUTOR_SENTRY_RELEASE",
178178
"EXECUTOR_SENTRY_ENVIRONMENT",
179179
"EXECUTOR_RUN_ID",
180+
// Analytics opt-out must survive into the supervised unit's minimal env,
181+
// or an opted-out install would silently re-enable analytics under launchd.
182+
"DO_NOT_TRACK",
183+
"EXECUTOR_DISABLE_ANALYTICS",
180184
] as const;
181185
const passThrough = Object.fromEntries(
182186
passThroughKeys.flatMap((key) => {

apps/host-selfhost/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"@cloudflare/worker-bundler": "0.2.1",
2323
"@effect/atom-react": "catalog:",
2424
"@effect/platform-bun": "catalog:",
25+
"@executor-js/analytics": "workspace:*",
2526
"@executor-js/api": "workspace:*",
2627
"@executor-js/app": "workspace:*",
2728
"@executor-js/execution": "workspace:*",
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { Effect, Layer, ManagedRuntime } from "effect";
2+
3+
import {
4+
Analytics,
5+
defaultLayer as analyticsDefaultLayer,
6+
withExecutionAnalytics,
7+
type AnalyticsService,
8+
} from "@executor-js/analytics";
9+
import { EngineDecorator } from "@executor-js/api/server";
10+
11+
import packageJson from "../package.json" with { type: "json" };
12+
import { resolveDataDir } from "./config";
13+
14+
// ---------------------------------------------------------------------------
15+
// Self-host product analytics: one anonymous per-install service for the
16+
// server lifetime. The anonymous id persists next to the instance's other
17+
// first-boot state (secret.key) under the data dir, so an instance counts
18+
// once across restarts. Module-singleton runtime; dispose flushes at shutdown.
19+
// ---------------------------------------------------------------------------
20+
21+
// Deployed self-host builds are published releases; the prerelease heuristic
22+
// mirrors apps/local/src/installation.ts.
23+
const VERSION: string = packageJson.version;
24+
const CHANNEL = VERSION.includes("-") ? ("beta" as const) : ("stable" as const);
25+
26+
let analyticsRuntime: ManagedRuntime.ManagedRuntime<Analytics, never> | null = null;
27+
28+
const getAnalyticsRuntime = (): ManagedRuntime.ManagedRuntime<Analytics, never> => {
29+
if (analyticsRuntime) return analyticsRuntime;
30+
analyticsRuntime = ManagedRuntime.make(
31+
analyticsDefaultLayer({
32+
surface: "selfhost",
33+
version: VERSION,
34+
channel: CHANNEL,
35+
dataDir: resolveDataDir(),
36+
}),
37+
);
38+
return analyticsRuntime;
39+
};
40+
41+
/**
42+
* Lazy facade over the instance's shared service. `record` forks into the
43+
* module runtime (fire-and-forget) so no request path ever waits on the
44+
* analytics layer; ordering within the runtime preserves event order.
45+
*/
46+
export const selfHostAnalytics: AnalyticsService = {
47+
record: (name, properties) =>
48+
Effect.sync(() => {
49+
getAnalyticsRuntime().runFork(
50+
Effect.flatMap(Analytics.asEffect(), (analytics) => analytics.record(name, properties)),
51+
);
52+
}),
53+
flush: Effect.promise(() =>
54+
getAnalyticsRuntime().runPromise(
55+
Effect.flatMap(Analytics.asEffect(), (analytics) => analytics.flush),
56+
),
57+
),
58+
};
59+
60+
/** Flush and dispose the analytics runtime (server shutdown). */
61+
export const disposeAnalytics = async (): Promise<void> => {
62+
const runtime = analyticsRuntime;
63+
if (!runtime) return;
64+
analyticsRuntime = null;
65+
await Effect.runPromise(Effect.promise(() => runtime.dispose()).pipe(Effect.ignoreCause()));
66+
};
67+
68+
/**
69+
* The execution-analytics `EngineDecorator`: every engine the shared stack
70+
* builds gets wrapped once, with the plane derived from what the stack was
71+
* built to serve — an MCP session carries its `mcpResource`, the HTTP
72+
* executions plane has none. The wrap point IS the plane, so misattribution
73+
* is structurally impossible.
74+
*/
75+
export const SelfHostAnalyticsEngineDecorator: Layer.Layer<EngineDecorator> = Layer.succeed(
76+
EngineDecorator,
77+
)({
78+
decorate: (engine, _identity, context) =>
79+
withExecutionAnalytics(engine, selfHostAnalytics, {
80+
plane: context.mcpResource === undefined ? "api" : "mcp",
81+
toolkit: context.mcpResource?.kind === "toolkit",
82+
}),
83+
});

apps/host-selfhost/src/app.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import { HttpApiSwagger } from "effect/unstable/httpapi";
22
import { HttpEffect, HttpRouter } from "effect/unstable/http";
33
import { Effect, Layer } from "effect";
44

5-
import { composePluginApi, ExecutorApp, textFailureStrategy } from "@executor-js/api/server";
5+
import {
6+
ArtifactUsageObserver,
7+
composePluginApi,
8+
ExecutorApp,
9+
textFailureStrategy,
10+
} from "@executor-js/api/server";
611

712
import { runSqliteDataMigrations } from "@executor-js/sdk";
813

@@ -14,6 +19,7 @@ import { makeSelfHostSystemApiLayer } from "./system/handlers";
1419
import { selfHostAccountMiddleware } from "./account";
1520
import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "./config";
1621
import { createSelfHostDb, SelfHostDb, SelfHostDbProvider } from "./db/self-host-db";
22+
import { selfHostAnalytics, SelfHostAnalyticsEngineDecorator } from "./analytics";
1723
import {
1824
SelfHostCodeExecutorProvider,
1925
SelfHostHostConfig,
@@ -98,7 +104,12 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => {
98104
identity: identityLayer,
99105
account: selfHostAccountMiddleware(betterAuth),
100106
db: SelfHostDbProvider,
101-
engine: { codeExecutor: SelfHostCodeExecutorProvider }, // decorator defaults to no-op (no metering)
107+
engine: {
108+
codeExecutor: SelfHostCodeExecutorProvider,
109+
// Anonymous execution analytics (this seam is the HTTP plane; the MCP
110+
// plane's decorator is wired in mcp/session-store.ts's stack layer).
111+
decorator: SelfHostAnalyticsEngineDecorator,
112+
},
102113
mcp: { auth: mcp.auth, sessions: mcp.sessions, reporter: mcp.reporter },
103114
plugins: { provider: SelfHostPluginsProvider, config: SelfHostHostConfig },
104115
errorCapture: ErrorCaptureLive,
@@ -131,8 +142,16 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => {
131142
config: { mountPrefix: "/api", failure: textFailureStrategy },
132143
// The boot-scoped context provideMerge'd under everything: the long-lived DB
133144
// handle (read by the DbProvider seam, Better Auth, and the MCP store) + the
134-
// resolved identity (captured once by the execution middleware + MCP auth).
135-
boot: Layer.merge(Layer.succeed(SelfHostDb)(dbHandle), identityLayer),
145+
// resolved identity (captured once by the execution middleware + MCP auth)
146+
// + the artifact-usage observer (this HTTP plane is the console UI's data
147+
// layer, so operations it serves file as `via: "ui"`).
148+
boot: Layer.mergeAll(
149+
Layer.succeed(SelfHostDb)(dbHandle),
150+
identityLayer,
151+
Layer.succeed(ArtifactUsageObserver)((action) =>
152+
selfHostAnalytics.record(`artifact_${action}`, { via: "ui" }),
153+
),
154+
),
136155
});
137156

138157
return {

apps/host-selfhost/src/execution.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ import {
44
CodeExecutorProvider,
55
DbProvider,
66
EngineDecorator,
7-
EngineDecoratorNoop,
87
HostConfig,
98
PluginsProvider,
109
} from "@executor-js/api/server";
1110
import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
1211

1312
import executorConfig from "../executor.config";
13+
import { selfHostAnalytics, SelfHostAnalyticsEngineDecorator } from "./analytics";
1414
import { SelfHostDb, SelfHostDbProvider } from "./db/self-host-db";
1515
import { loadConfig } from "./config";
1616

@@ -21,7 +21,7 @@ import { loadConfig } from "./config";
2121
// makeScopedExecutor -> createExecutionEngine -> EngineDecorator.decorate.
2222
// Self-host just supplies the five seam Layers it reads from. Differences from
2323
// cloud: the QuickJS in-process code substrate (vs the Cloudflare dynamic
24-
// worker) and a NO-OP engine decorator (no usage metering).
24+
// worker) and an analytics engine decorator instead of cloud's usage metering.
2525
//
2626
// - DbProvider -> SelfHostDbProvider: projects the long-lived
2727
// libSQL handle (built once at boot, see db/). The
@@ -33,7 +33,8 @@ import { loadConfig } from "./config";
3333
// - HostConfig -> `{ allowLocalNetwork, webBaseUrl }` from
3434
// `loadConfig()`.
3535
// - CodeExecutorProvider -> `makeQuickJsExecutor()`.
36-
// - EngineDecorator -> no-op (self-host does not meter executions).
36+
// - EngineDecorator -> execution analytics (anonymous per-install
37+
// counters; see ./analytics.ts).
3738
// ---------------------------------------------------------------------------
3839

3940
export { makeExecutionStack } from "@executor-js/api/server";
@@ -54,6 +55,11 @@ export const SelfHostHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig
5455
allowLocalNetwork: config.allowLocalNetwork,
5556
webBaseUrl: config.webBaseUrl,
5657
oauthCallbackPath: "/api/oauth/callback",
58+
onIntegrationChange: (event) =>
59+
selfHostAnalytics.record(
60+
event.kind === "added" ? "integration_added" : "integration_removed",
61+
{ plugin_key: event.pluginKey },
62+
),
5763
};
5864
});
5965

@@ -82,4 +88,8 @@ export const SelfHostExecutionStackLayer: Layer.Layer<
8288
DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator,
8389
never,
8490
SelfHostDb
85-
> = Layer.mergeAll(SelfHostScopedExecutorSeams, SelfHostCodeExecutorProvider, EngineDecoratorNoop);
91+
> = Layer.mergeAll(
92+
SelfHostScopedExecutorSeams,
93+
SelfHostCodeExecutorProvider,
94+
SelfHostAnalyticsEngineDecorator,
95+
);

apps/host-selfhost/src/mcp/session-store.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
type InMemoryMcpSessionStore,
99
} from "@executor-js/host-mcp/in-memory-session-store";
1010

11+
import { selfHostAnalytics } from "../analytics";
1112
import { ErrorCaptureLive } from "../observability";
1213
import { SelfHostDb, type SelfHostDbHandle } from "../db/self-host-db";
1314
import { SelfHostExecutionStackLayer } from "../execution";
@@ -39,7 +40,13 @@ export const makeSelfHostMcpSessionStore = (
3940
makeInMemoryMcpSessionStore(
4041
makeMcpBuildServer(
4142
SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))),
42-
{ loadAppShellHtml: loadMcpAppsShellHtml, smokeRenderArtifact },
43+
{
44+
loadAppShellHtml: loadMcpAppsShellHtml,
45+
smokeRenderArtifact,
46+
// Artifact operations on the MCP plane come from an agent's tools.
47+
onArtifactUsage: (action) =>
48+
selfHostAnalytics.record(`artifact_${action}`, { via: "agent" }),
49+
},
4350
),
4451
{ webBaseUrl },
4552
);

apps/host-selfhost/src/serve.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
import { BunFileSystem, BunHttpServer, BunPath, BunRuntime } from "@effect/platform-bun";
2727
import { Effect, Layer } from "effect";
2828

29+
import { disposeAnalytics } from "./analytics";
2930
import { makeSelfHostApp } from "./app";
3031
import { loadConfig } from "./config";
3132
import type { BetterAuthHandle } from "./auth";
@@ -120,6 +121,11 @@ export const startServer = async (): Promise<void> => {
120121
cacheControl: "no-cache",
121122
}).pipe(Layer.provide(BunFileSystem.layer), Layer.provide(BunPath.layer));
122123

124+
// Server-scope finalizer: flush buffered analytics on graceful shutdown.
125+
const AnalyticsFlushLive = Layer.effectDiscard(
126+
Effect.addFinalizer(() => Effect.promise(() => disposeAnalytics())),
127+
);
128+
123129
const ServerLive = HttpRouter.serve(Layer.mergeAll(AppLayer, AssetsLive, SpaLive), {
124130
middleware: selfHostHttpMiddleware(betterAuth),
125131
}).pipe(
@@ -128,7 +134,7 @@ export const startServer = async (): Promise<void> => {
128134
),
129135
);
130136

131-
await BunRuntime.runMain(Layer.launch(ServerLive));
137+
await BunRuntime.runMain(Layer.launch(Layer.merge(ServerLive, AnalyticsFlushLive)));
132138
};
133139

134140
if (import.meta.main) {

apps/local/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"dependencies": {
2323
"@effect/atom-react": "catalog:",
2424
"@effect/platform-node": "catalog:",
25+
"@executor-js/analytics": "workspace:*",
2526
"@executor-js/api": "workspace:*",
2627
"@executor-js/app": "workspace:*",
2728
"@executor-js/config": "workspace:*",

0 commit comments

Comments
 (0)