@farm.js/telemetry 0.0.0-stage → 0.1.0-beta.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Farming Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,155 @@
1
- # Temporary Holding Version
1
+ # Farm.js telemetry
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Server-only, opt-in request and native MCP metadata collection for Farm Infra.
4
+ Requires **Farm.js core 0.1.2 or later**. Native MCP correlation additionally requires
5
+ **`@farm.js/mcp` 0.1.0-beta.2 or later**; older MCP packages do not emit the required
6
+ request-scoped tool outcomes. Keep your app's shared Farm packages on the same version.
7
+
8
+ This is a release candidate. Publish the matching framework versions before publishing
9
+ this package or advertising the following install command as available.
10
+
11
+ ```sh
12
+ pnpm add @farm.js/telemetry@beta
13
+ ```
14
+
15
+ In your server-side `farm.config.ts`:
16
+
17
+ ```ts
18
+ import { defineConfig } from "@farm.js/core";
19
+ import { telemetry } from "@farm.js/telemetry";
20
+
21
+ export default defineConfig({
22
+ plugins: [telemetry({ projectId: "proj_…" })], // the project ID shown in Farm Infra
23
+ });
24
+ ```
25
+
26
+ Then set the key in the server environment and redeploy the app directory:
27
+
28
+ ```sh
29
+ FARMJS_API_KEY=farmjs_… # an account-wide Farm Infra API key
30
+ FARMJS_API_URL=https://… # only if Farm Infra isn't at https://infra.farmjs.dev
31
+ ```
32
+
33
+ The plugin reads both itself; pass `key` or `endpoint` to override them. Without a key it
34
+ stays off and installs no hooks, so local development needs no setup. Farm evaluates the
35
+ config when it builds and again when the server starts, so make the key available to both
36
+ (Vercel environment variables are). `enabled: true` turns a missing key into an error.
37
+
38
+ The key's owner must be a member of the project's organization, and that is checked on
39
+ every batch, so the key can't write to another organization's project whatever `projectId`
40
+ says. Preview-only (project-restricted) keys are refused. The environment defaults to
41
+ Vercel's (`preview` counts as `staging`), then `NODE_ENV`; set
42
+ `environment: "production" | "staging" | "development"` to choose it yourself.
43
+
44
+ An account key belongs to a person: it stops sending if they revoke it or leave the
45
+ organization, and on a server it can also start previews and run site checks as them.
46
+ For a credential that only writes telemetry, create a write-only key for the project and
47
+ environment in Infra and set that as `FARMJS_API_KEY` instead; it names its own project and
48
+ environment, so `projectId` isn't needed.
49
+
50
+ Nothing automatically enables collection, changes MCP authorization, or proxies the app
51
+ through Infra. Omit the plugin or set `enabled: false` to install no runtime hooks.
52
+
53
+ ## What is collected
54
+
55
+ - Request UUID, matched route **template**, method, response status and time to the
56
+ runtime response boundary (not streamed-body duration or proof of client delivery).
57
+ - Native registered MCP tool callback completion, tool name, duration and actual
58
+ success/error outcome. An MCP error inside HTTP 200 remains a tool error.
59
+ - Tool and request records share a server-generated UUID held in private plugin
60
+ context. No extra request/response headers are added. Other app instances and
61
+ unscoped MCP events are excluded.
62
+ - Incoming user-agent tokens can produce **self-reported**, never verified identities.
63
+ Initial detection covers OpenAI's GPTBot/OAI-SearchBot/ChatGPT-User and Anthropic's
64
+ ClaudeBot/Claude-SearchBot/Claude-User. See the
65
+ [OpenAI crawler reference](https://developers.openai.com/api/docs/bots) and
66
+ [Anthropic crawler reference](https://support.claude.com/en/articles/8896518-does-anthropic-crawl-data-from-the-web-and-how-can-site-owners-block-the-crawler).
67
+ Missing, ambiguous, browser, curl and unrecognized harness identifiers stay Unknown.
68
+ This does not verify IP ranges, signatures, identity, citations or agent runs.
69
+
70
+ No raw URLs, parameters, query strings, user agents, IPs, cookies, authorization,
71
+ headers, bodies, prompts, tool arguments/results, or exception messages are exported.
72
+ Configured route templates and tool names are metadata; do not put secrets in their
73
+ definitions. Unmatched routes use `/[unmatched]`.
74
+
75
+ ## Delivery and coverage
76
+
77
+ At most 500 events in memory, batches of 50 (128 KiB ceiling), five attempts per event,
78
+ five-minute queue age, two-second fetch deadlines and five-second flush budgets.
79
+ The runtime hook registers each drain with Farm's `waitUntil`, without awaiting it in
80
+ the response path. Retries retain IDs for receiver deduplication. A longer Retry-After
81
+ pauses delivery until later traffic triggers a flush; it does not hold serverless
82
+ requests open for minutes/hours. Queued events may expire first. Shutdown unregisters
83
+ the listener, attempts a bounded flush and releases the queue. This is best-effort,
84
+ not a durable audit log; process termination and runtime lifetime limits can drop events.
85
+ Transport failures never change an application response. `onDiagnostic` optionally
86
+ reports only a small code, count and HTTP status; observer errors are contained.
87
+
88
+ Invalid keys/config fail clearly when enabling the plugin. Receiver 400/401/403/404/413/415
89
+ disable that sender instance until reconfigured/restarted. Requests use HTTPS (or
90
+ explicit loopback HTTP for development), reject redirects, and do not read/log response
91
+ bodies. Ingestion keys never appear in a URL or browser config.
92
+
93
+ Only traffic reaching Farm's hooked runtime is visible. CDN/static hits, WAF blocks,
94
+ provider-side traffic and outside agent runs are not covered. Global request hooks can
95
+ affect Farm's runtime/static optimization eligibility; disabled collection adds no hooks.
96
+ Assets are excluded. Unhandled errors without a final Response at this boundary are
97
+ not assigned invented HTTP status codes. MCP authorization/SDK rejections before a
98
+ registered tool callback do not become tool executions. Renderers share the server
99
+ hooks; the initial sender targets Node-compatible runtimes. Edge portability is not claimed.
100
+
101
+ ## Verify
102
+
103
+ ```sh
104
+ pnpm --filter @farm.js/telemetry test
105
+ pnpm --filter @farm.js/telemetry type-check
106
+ pnpm --filter @farm.js/telemetry build
107
+ ```
108
+
109
+ Tests exercise the actual Farm plugin manager, concurrent request scopes, metadata
110
+ compatibility with the receiver, bounded delivery/retries and shutdown. Runtime smoke
111
+ verification uses a matching built Farm checkout and a loopback receiver only; no
112
+ production credentials, database or destination are needed.
113
+
114
+ ```sh
115
+ FARM_TELEMETRY_FRAMEWORK_DIR=/absolute/path/to/matching/farm.js \
116
+ pnpm --filter @farm.js/telemetry test:runtime
117
+ ```
118
+
119
+ Build core and MCP in that checkout first. The check packs the SDK, verifies its file
120
+ allowlist, installs that tarball into an isolated fixture and checks its JavaScript
121
+ and declarations without accessing the SDK sources. It starts a real Vite app, builds and
122
+ loads its Vercel function, sends HTTP requests and an authenticated native MCP call,
123
+ and validates received metadata against Infra's v1 contract. It checks route-template
124
+ redaction, caller labels, correlated tool errors under HTTP 200, and the absence of
125
+ fixture credentials in client JavaScript. The receiver is loopback-only and in-memory;
126
+ receiver database authorization/retention is covered by the separate PostgreSQL suite.
127
+
128
+ To verify the complete path through a running Infra server, start Infra against an
129
+ isolated, migrated **local test database**, then run:
130
+
131
+ ```sh
132
+ DATABASE_URL=postgresql://localhost/YOUR_ISOLATED_TEST_DATABASE \
133
+ INFRA_TEST_URL=http://localhost:3013 \
134
+ TELEMETRY_RUNTIME_BROWSER_E2E=1 \
135
+ FARM_TELEMETRY_FRAMEWORK_DIR=/absolute/path/to/matching/farm.js \
136
+ pnpm --dir apps/web exec playwright test tests/browser/telemetry-runtime.e2e.spec.ts
137
+ ```
138
+
139
+ This creates a fixture account and project-scoped ingestion key, runs both Farm
140
+ execution modes, forwards the recorded exporter batches unchanged into the real
141
+ ingestion API, and checks PostgreSQL records and the dashboard's correlated MCP
142
+ error details. It seeds only the repository snapshot: it does **not** verify GitHub
143
+ App installation or repository authorization. The project and its events/keys are
144
+ removed afterward; the disposable account remains in the isolated test database.
145
+ The optional runtime-driver forwarding URL accepts only loopback HTTP, and its
146
+ ephemeral key is passed through the child process environment, never command arguments.
147
+
148
+ ## Publishing
149
+
150
+ Publish only a reviewed, committed package after the checks above pass against the
151
+ matching released core and MCP versions. From this directory, run `npm publish --tag beta`.
152
+ The `prepack` hook builds declarations and JavaScript; the explicit `files` allowlist
153
+ excludes tests and local configuration. Verify the exact version on npm and install it
154
+ in a separate app before announcing availability. Do not promote this early-access
155
+ sender to `latest` as part of the framework's independent release process.
@@ -0,0 +1,26 @@
1
+ import type { MetadataEvent } from "./metadata.js";
2
+ export type Diagnostic = {
3
+ code: "dropped" | "retry" | "disabled";
4
+ count?: number;
5
+ status?: number;
6
+ };
7
+ export type Environment = "production" | "staging" | "development";
8
+ export interface ExporterOptions {
9
+ endpoint: string;
10
+ /** A Farm Infra API key (`farmjs_…`), or a project's write-only ingestion key (`fti_…`). */
11
+ key: string;
12
+ /** The project ID shown in Farm Infra. Needed with an API key; an ingestion key names its own. */
13
+ projectId?: string;
14
+ /** Defaults to Vercel's environment (preview counts as staging), then NODE_ENV. */
15
+ environment?: Environment;
16
+ onDiagnostic?: (event: Diagnostic) => void | Promise<void>;
17
+ }
18
+ /** Where the app is running, when the config doesn't say. */
19
+ export declare function defaultEnvironment(env?: Record<string, string | undefined>): Environment;
20
+ export declare function validateDestination(options: ExporterOptions): void;
21
+ /** Bounded best-effort delivery. Never retain a Request, Response, or payload body. */
22
+ export declare function createExporter(options: ExporterOptions, send?: typeof fetch): {
23
+ enqueue: (event: MetadataEvent) => void;
24
+ flush: () => Promise<void>;
25
+ close: () => Promise<void>;
26
+ };
@@ -0,0 +1,210 @@
1
+ const INGESTION_KEY = /^fti_[A-Za-z0-9_-]{43}$/;
2
+ const API_KEY = /^farmjs_[A-Za-z0-9_-]{32}$/;
3
+ const PROJECT = /^proj_[0-9a-f-]{36}$/;
4
+ const ENVIRONMENTS = ["production", "staging", "development"];
5
+ /** Where the app is running, when the config doesn't say. */
6
+ export function defaultEnvironment(env = process.env) {
7
+ if (env.VERCEL_ENV === "production")
8
+ return "production";
9
+ if (env.VERCEL_ENV === "preview")
10
+ return "staging";
11
+ if (env.VERCEL_ENV === "development")
12
+ return "development";
13
+ return env.NODE_ENV === "production" ? "production" : "development";
14
+ }
15
+ const MAX_QUEUE = 500;
16
+ const MAX_AGE = 300_000;
17
+ const MAX_ATTEMPTS = 5;
18
+ const FLUSH_BUDGET = 5_000;
19
+ const TIMEOUT = 2_000;
20
+ const encoder = new TextEncoder();
21
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
22
+ export function validateDestination(options) {
23
+ const url = new URL(options.endpoint);
24
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
25
+ if ((url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) ||
26
+ url.username ||
27
+ url.password ||
28
+ url.search ||
29
+ url.hash ||
30
+ url.pathname !== "/api/telemetry/v1/events") {
31
+ throw new TypeError("Use the HTTPS Farm Infra /api/telemetry/v1/events endpoint (HTTP is local-only).");
32
+ }
33
+ if (INGESTION_KEY.test(options.key))
34
+ return;
35
+ if (!API_KEY.test(options.key)) {
36
+ throw new TypeError("Set FARMJS_API_KEY to a Farm Infra API key (farmjs_…), or pass `key` (an API key or a write-only fti_… key).");
37
+ }
38
+ if (!options.projectId || !PROJECT.test(options.projectId)) {
39
+ throw new TypeError("Set `projectId` to the project ID shown in Farm Infra (proj_…).");
40
+ }
41
+ if (options.environment !== undefined && !ENVIRONMENTS.includes(options.environment)) {
42
+ throw new TypeError("Set `environment` to production, staging or development.");
43
+ }
44
+ }
45
+ /** Request headers: an API key also names the project and environment it writes to. */
46
+ function headers(options) {
47
+ const base = { "content-type": "application/json", authorization: `Bearer ${options.key}` };
48
+ if (INGESTION_KEY.test(options.key))
49
+ return base;
50
+ return {
51
+ ...base,
52
+ "x-farm-project": options.projectId,
53
+ "x-farm-environment": options.environment ?? defaultEnvironment(),
54
+ };
55
+ }
56
+ function retryAt(value, now) {
57
+ if (!value)
58
+ return now;
59
+ if (/^\d+$/.test(value.trim()))
60
+ return now + Math.min(Number(value), 86_400) * 1000;
61
+ const date = Date.parse(value);
62
+ return Number.isFinite(date) ? Math.max(now, Math.min(date, now + 86_400_000)) : now;
63
+ }
64
+ /** Bounded best-effort delivery. Never retain a Request, Response, or payload body. */
65
+ export function createExporter(options, send = fetch) {
66
+ validateDestination(options);
67
+ const requestHeaders = headers(options);
68
+ const queue = [];
69
+ let stopped = false;
70
+ let closing = false;
71
+ let pending;
72
+ let notBefore = 0;
73
+ let active;
74
+ let inFlight = new Set();
75
+ const diagnostic = (event) => {
76
+ try {
77
+ void Promise.resolve(options.onDiagnostic?.(event)).catch(() => { });
78
+ }
79
+ catch {
80
+ /* Optional observer. */
81
+ }
82
+ };
83
+ function prune() {
84
+ const now = Date.now();
85
+ const expired = queue.filter((item) => !inFlight.has(item) && (now - item.enqueuedAt >= MAX_AGE || item.attempts >= MAX_ATTEMPTS));
86
+ if (expired.length) {
87
+ for (let i = queue.length - 1; i >= 0; i--) {
88
+ if (!inFlight.has(queue[i]) &&
89
+ (now - queue[i].enqueuedAt >= MAX_AGE || queue[i].attempts >= MAX_ATTEMPTS))
90
+ queue.splice(i, 1);
91
+ }
92
+ diagnostic({ code: "dropped", count: expired.length });
93
+ }
94
+ }
95
+ function remove(batch) {
96
+ const sent = new Set(batch);
97
+ for (let i = queue.length - 1; i >= 0; i--)
98
+ if (sent.has(queue[i]))
99
+ queue.splice(i, 1);
100
+ }
101
+ function enqueue(event) {
102
+ if (stopped || closing)
103
+ return;
104
+ prune();
105
+ if (queue.length >= MAX_QUEUE) {
106
+ diagnostic({ code: "dropped", count: 1 });
107
+ return;
108
+ }
109
+ queue.push({ event, enqueuedAt: Date.now(), attempts: 0 });
110
+ }
111
+ async function drain() {
112
+ const deadline = Date.now() + FLUSH_BUDGET;
113
+ // Coalesce simultaneous completions while keeping the entire drain in waitUntil.
114
+ await sleep(25);
115
+ while (!stopped && queue.length && Date.now() < deadline) {
116
+ prune();
117
+ if (!queue.length)
118
+ break;
119
+ if (notBefore >= deadline)
120
+ break;
121
+ if (notBefore > Date.now())
122
+ await sleep(notBefore - Date.now());
123
+ if (stopped || Date.now() >= deadline)
124
+ break;
125
+ const batch = queue.slice(0, 50);
126
+ const body = JSON.stringify({ schemaVersion: 1, events: batch.map(({ event }) => event) });
127
+ if (encoder.encode(body).length > 128 * 1024) {
128
+ queue.splice(0, batch.length);
129
+ diagnostic({ code: "dropped", count: batch.length });
130
+ continue;
131
+ }
132
+ for (const item of batch)
133
+ item.attempts++;
134
+ inFlight = new Set(batch);
135
+ active = new AbortController();
136
+ const timeout = setTimeout(() => active?.abort(), Math.min(TIMEOUT, deadline - Date.now()));
137
+ let status;
138
+ try {
139
+ const response = await send(options.endpoint, {
140
+ method: "POST",
141
+ redirect: "error",
142
+ signal: active.signal,
143
+ headers: requestHeaders,
144
+ body,
145
+ });
146
+ status = response.status;
147
+ // The receipt body is not needed; never wait for or log arbitrary response text.
148
+ void response.body?.cancel().catch(() => { });
149
+ if (response.ok) {
150
+ remove(batch);
151
+ continue;
152
+ }
153
+ if (status === 401 ||
154
+ status === 403 ||
155
+ // An API key naming a project its owner can't reach.
156
+ status === 404 ||
157
+ status === 400 ||
158
+ status === 413 ||
159
+ status === 415 ||
160
+ (status >= 300 && status < 400)) {
161
+ stopped = true;
162
+ queue.length = 0;
163
+ diagnostic({ code: "disabled", status });
164
+ break;
165
+ }
166
+ if (status !== 408 && status !== 429 && status < 500) {
167
+ remove(batch);
168
+ diagnostic({ code: "dropped", count: batch.length, status });
169
+ continue;
170
+ }
171
+ notBefore = Math.max(notBefore, retryAt(response.headers.get("retry-after"), Date.now()));
172
+ }
173
+ catch {
174
+ /* Network failure or deadline: keep the same event IDs for a bounded retry. */
175
+ }
176
+ finally {
177
+ clearTimeout(timeout);
178
+ active = undefined;
179
+ inFlight.clear();
180
+ }
181
+ notBefore = Math.max(notBefore, Date.now() + Math.min(30_000, 500 * 2 ** (batch[0].attempts - 1)));
182
+ diagnostic({ code: "retry", ...(status === undefined ? {} : { status }) });
183
+ }
184
+ }
185
+ function flush() {
186
+ if (stopped || !queue.length || notBefore > Date.now() + FLUSH_BUDGET)
187
+ return pending ?? Promise.resolve();
188
+ if (!pending)
189
+ pending = drain()
190
+ .catch(() => { })
191
+ .finally(() => {
192
+ pending = undefined;
193
+ });
194
+ return pending;
195
+ }
196
+ async function close() {
197
+ if (closing) {
198
+ await pending;
199
+ return;
200
+ }
201
+ closing = true;
202
+ await flush();
203
+ stopped = true;
204
+ active?.abort();
205
+ if (queue.length)
206
+ diagnostic({ code: "dropped", count: queue.length });
207
+ queue.length = 0;
208
+ }
209
+ return { enqueue, flush, close };
210
+ }
@@ -0,0 +1,19 @@
1
+ import type { FarmPlugin } from "@farm.js/core/plugin";
2
+ import { createExporter, type ExporterOptions } from "./exporter.js";
3
+ export type { Diagnostic, Environment } from "./exporter.js";
4
+ /**
5
+ * Everything is optional: the key comes from FARMJS_API_KEY and Farm Infra's address from
6
+ * FARMJS_API_URL, so an app usually passes only its `projectId`. Without a key the plugin
7
+ * stays off (unless `enabled: true`, which then fails clearly).
8
+ */
9
+ export type TelemetryOptions = Partial<ExporterOptions> & {
10
+ enabled?: boolean;
11
+ };
12
+ /** The options with the key and endpoint the environment provides filled in. */
13
+ export declare function resolveOptions(options: TelemetryOptions, env?: Record<string, string | undefined>): ExporterOptions;
14
+ interface State {
15
+ exporter: ReturnType<typeof createExporter>;
16
+ unsubscribe?: () => void;
17
+ }
18
+ /** Server-only, explicitly configured project telemetry. No browser client or automatic destination. */
19
+ export declare function telemetry(input?: TelemetryOptions): FarmPlugin<State>;
package/dist/index.js ADDED
@@ -0,0 +1,115 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { onFarmEvent } from "@farm.js/core/observability";
3
+ import { getCurrentRequest } from "@farm.js/core/request";
4
+ import { createExporter, validateDestination } from "./exporter.js";
5
+ import { classifyAgent, duration, isToolEvent, routeTemplate } from "./metadata.js";
6
+ const DEFAULT_URL = "https://infra.farmjs.dev";
7
+ /** The options with the key and endpoint the environment provides filled in. */
8
+ export function resolveOptions(options, env = process.env) {
9
+ let endpoint = options.endpoint;
10
+ if (endpoint === undefined) {
11
+ try {
12
+ endpoint = new URL("/api/telemetry/v1/events", env.FARMJS_API_URL || DEFAULT_URL).href;
13
+ }
14
+ catch {
15
+ throw new TypeError("Set FARMJS_API_URL to Farm Infra's URL, like https://infra.farmjs.dev.");
16
+ }
17
+ }
18
+ return { ...options, endpoint, key: options.key ?? env.FARMJS_API_KEY ?? "" };
19
+ }
20
+ /** Server-only, explicitly configured project telemetry. No browser client or automatic destination. */
21
+ export function telemetry(input = {}) {
22
+ // A disabled plugin installs no request hooks, so it does not change static eligibility.
23
+ // Farm evaluates the config at build and at server start, so the key must exist at both.
24
+ if (input.enabled === false)
25
+ return { name: "farm-infra-telemetry" };
26
+ const options = resolveOptions(input);
27
+ if (input.enabled === undefined && !options.key)
28
+ return { name: "farm-infra-telemetry" };
29
+ validateDestination(options);
30
+ const contextKey = `farm.infra.telemetry.${randomUUID()}`;
31
+ return {
32
+ name: "farm-infra-telemetry",
33
+ enforce: "post",
34
+ setup(context) {
35
+ const state = { exporter: createExporter(options) };
36
+ context.lifecycle.onShutdown(async () => {
37
+ state.unsubscribe?.();
38
+ await state.exporter.close();
39
+ });
40
+ return state;
41
+ },
42
+ runtime: {
43
+ start(context) {
44
+ const { exporter } = context.state;
45
+ // The listener is scoped by a private, per-plugin key, never caller-controlled headers.
46
+ context.state.unsubscribe = onFarmEvent((event) => {
47
+ if (!isToolEvent(event))
48
+ return;
49
+ let request;
50
+ try {
51
+ request = getCurrentRequest();
52
+ }
53
+ catch {
54
+ return;
55
+ }
56
+ const metadata = context.requestContext.get(request, contextKey);
57
+ // Streaming MCP transports can return their HTTP Response before the tool finishes.
58
+ if (!metadata)
59
+ return;
60
+ exporter.enqueue({
61
+ id: randomUUID(),
62
+ requestId: metadata.id,
63
+ timestamp: new Date().toISOString(),
64
+ route: metadata.route,
65
+ agent: metadata.agent,
66
+ kind: "mcp.tool",
67
+ tool: event.tool,
68
+ outcome: event.outcome,
69
+ durationMs: duration(event.durationMs),
70
+ });
71
+ metadata.waitUntil(exporter.flush());
72
+ }, { unfiltered: true });
73
+ },
74
+ before(event) {
75
+ if (event.kind === "asset" || !event.state)
76
+ return;
77
+ event.req.set(contextKey, {
78
+ id: randomUUID(),
79
+ agent: classifyAgent(event.request.headers.get("user-agent")),
80
+ route: routeTemplate(event.route?.pattern),
81
+ completed: false,
82
+ waitUntil: event.waitUntil,
83
+ });
84
+ },
85
+ after(event) {
86
+ const metadata = event.req.get(contextKey);
87
+ if (!metadata || metadata.completed || !event.state)
88
+ return;
89
+ metadata.completed = true;
90
+ if (!["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "QUERY"].includes(event.request.method))
91
+ return;
92
+ event.state.exporter.enqueue({
93
+ id: randomUUID(),
94
+ requestId: metadata.id,
95
+ timestamp: new Date().toISOString(),
96
+ route: metadata.route,
97
+ agent: metadata.agent,
98
+ kind: "request",
99
+ method: event.request.method,
100
+ status: event.response.status,
101
+ durationMs: duration(event.durationMs),
102
+ });
103
+ event.waitUntil(event.state.exporter.flush());
104
+ },
105
+ // Unhandled exceptions have no final HTTP status at this boundary. Do not fabricate a 500.
106
+ error(event) {
107
+ const metadata = event.req.get(contextKey);
108
+ if (metadata)
109
+ metadata.completed = true;
110
+ if (event.state)
111
+ event.waitUntil(event.state.exporter.flush());
112
+ },
113
+ },
114
+ };
115
+ }
@@ -0,0 +1,32 @@
1
+ export interface AgentIdentity {
2
+ family: "unknown" | "openai" | "anthropic" | "google" | "microsoft" | "perplexity" | "other";
3
+ category: "unknown" | "crawler" | "agent" | "harness";
4
+ identity: "unknown" | "self-reported";
5
+ }
6
+ interface EventBase {
7
+ id: string;
8
+ timestamp: string;
9
+ durationMs: number;
10
+ route: string;
11
+ requestId: string;
12
+ agent: AgentIdentity;
13
+ }
14
+ export type MetadataEvent = EventBase & ({
15
+ kind: "request";
16
+ method: string;
17
+ status: number;
18
+ } | {
19
+ kind: "mcp.tool";
20
+ tool: string;
21
+ outcome: "success" | "error";
22
+ });
23
+ export declare function classifyAgent(userAgent: string | null): AgentIdentity;
24
+ export declare function routeTemplate(pattern: unknown): string;
25
+ export declare function duration(value: number): number;
26
+ export declare function isToolEvent(value: unknown): value is {
27
+ type: "mcp.tool.complete";
28
+ tool: string;
29
+ outcome: "success" | "error";
30
+ durationMs: number;
31
+ };
32
+ export {};
@@ -0,0 +1,46 @@
1
+ // These are declarations, NOT proof of identity. Keep raw headers out of the event.
2
+ // Sources and the deliberately limited initial coverage are documented in README.md.
3
+ const tokens = [
4
+ ["ChatGPT-User", "openai", "agent"],
5
+ ["OAI-SearchBot", "openai", "crawler"],
6
+ ["GPTBot", "openai", "crawler"],
7
+ ["Claude-User", "anthropic", "agent"],
8
+ ["Claude-SearchBot", "anthropic", "crawler"],
9
+ ["ClaudeBot", "anthropic", "crawler"],
10
+ ];
11
+ const patterns = tokens.map(([token, family, category]) => ({
12
+ pattern: new RegExp(`(?:^|[\\s;(])${token}(?=[/\\s;)]|$)`, "i"),
13
+ family,
14
+ category,
15
+ }));
16
+ export function classifyAgent(userAgent) {
17
+ const value = (userAgent ?? "").slice(0, 2048);
18
+ const matches = patterns.filter(({ pattern }) => pattern.test(value));
19
+ // Conflicting tokens are ambiguous, rather than a reason to guess a vendor.
20
+ if (matches.length === 1) {
21
+ const { family, category } = matches[0];
22
+ return { family, category, identity: "self-reported" };
23
+ }
24
+ return { family: "unknown", category: "unknown", identity: "unknown" };
25
+ }
26
+ export function routeTemplate(pattern) {
27
+ return typeof pattern === "string" &&
28
+ pattern.length <= 256 &&
29
+ /^\/(?!\/)[a-zA-Z0-9_./:[\]()*-]*$/.test(pattern)
30
+ ? pattern
31
+ : "/[unmatched]";
32
+ }
33
+ export function duration(value) {
34
+ return Number.isFinite(value) ? Math.min(86_400_000, Math.max(0, value)) : 0;
35
+ }
36
+ export function isToolEvent(value) {
37
+ if (!value || typeof value !== "object")
38
+ return false;
39
+ const event = value;
40
+ return (event.type === "mcp.tool.complete" &&
41
+ typeof event.tool === "string" &&
42
+ /^[a-zA-Z0-9_.:/-]{1,128}$/.test(event.tool) &&
43
+ (event.outcome === "success" || event.outcome === "error") &&
44
+ typeof event.durationMs === "number" &&
45
+ Number.isFinite(event.durationMs));
46
+ }
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "@farm.js/telemetry",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-beta.1",
4
+ "description": "Opt-in, server-only Farm Infra request and MCP metadata sender.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/farming-labs/farmjs-infra.git",
9
+ "directory": "packages/telemetry"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "type": "module",
15
+ "main": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js"
21
+ }
22
+ },
23
+ "publishConfig": {
24
+ "access": "public",
25
+ "tag": "beta"
26
+ },
27
+ "scripts": {
28
+ "build": "tsc -p tsconfig.build.json",
29
+ "test": "vitest run",
30
+ "type-check": "tsc --noEmit",
31
+ "test:runtime": "pnpm build && node --experimental-strip-types tests/runtime.mjs",
32
+ "prepack": "pnpm build"
33
+ },
34
+ "devDependencies": {
35
+ "@farm.js/core": "0.1.1-beta.2",
36
+ "@types/node": "22.20.1",
37
+ "typescript": "^5.3.3",
38
+ "vitest": "5.0.3"
39
+ },
40
+ "peerDependencies": {
41
+ "@farm.js/core": ">=0.1.2"
42
+ },
43
+ "engines": {
44
+ "node": ">=22.13.0"
45
+ }
46
+ }