@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 +21 -0
- package/README.md +154 -2
- package/dist/exporter.d.ts +26 -0
- package/dist/exporter.js +210 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +115 -0
- package/dist/metadata.d.ts +32 -0
- package/dist/metadata.js +46 -0
- package/package.json +44 -4
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
|
-
#
|
|
1
|
+
# Farm.js telemetry
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
};
|
package/dist/exporter.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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 {};
|
package/dist/metadata.js
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|