From 0075cd6129012492045ef36b0b3065cfd8549509 Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Fri, 14 Aug 2026 14:51:19 +0300 Subject: [PATCH] feat: report sdk name and version via redis telemetry headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every feature that takes a redis client now appends its package tag to the client's Upstash-Telemetry-Sdk header, matching @upstash/ratelimit. Opt out with `enableTelemetry: false` on any config, on the redis client itself, or with the UPSTASH_DISABLE_TELEMETRY env var. The per-package VERSION constants are stamped by scripts/sync-version.mjs, run from `ci:version` at release time only — never from build/dev, which must not rewrite tracked source. CI verifies them with the script's read-only --check. --- .changeset/olive-donuts-shave.md | 15 ++ .github/workflows/ci.yml | 5 + CLAUDE.md | 26 +++ README.md | 10 +- eslint.config.js | 9 + package.json | 2 +- packages/ai-sdk/README.md | 15 ++ packages/ai-sdk/src/chat-history.ts | 10 +- packages/ai-sdk/src/memory.ts | 15 +- packages/ai-sdk/src/search-tools.ts | 5 +- packages/ai-sdk/src/telemetry.test.ts | 115 +++++++++ packages/ai-sdk/src/telemetry.ts | 15 ++ packages/ai-sdk/src/tools.ts | 14 +- packages/ai-sdk/src/version.ts | 2 + packages/eve-extension/README.md | 15 ++ packages/eve-extension/extension/extension.ts | 5 + .../eve-extension/extension/lib/runtime.ts | 13 +- .../eve-extension/extension/lib/version.ts | 2 + packages/eve-extension/test/telemetry.test.ts | 83 +++++++ packages/eve-extension/tsconfig.json | 2 +- packages/eve/README.md | 15 ++ packages/eve/src/auth.ts | 5 +- packages/eve/src/memory.ts | 13 +- packages/eve/src/sandbox.ts | 12 +- packages/eve/src/search-tools.ts | 5 +- packages/eve/src/telemetry.test.ts | 82 +++++++ packages/eve/src/telemetry.ts | 15 ++ packages/eve/src/tools.ts | 15 +- packages/eve/src/version.ts | 2 + packages/sdk/README.md | 15 ++ packages/sdk/src/chat-history.ts | 8 + packages/sdk/src/index.ts | 6 + packages/sdk/src/memory.ts | 8 + packages/sdk/src/rate-limit.ts | 10 +- packages/sdk/src/reactive-index.ts | 7 + packages/sdk/src/search-tools.ts | 15 +- packages/sdk/src/telemetry.test.ts | 220 ++++++++++++++++++ packages/sdk/src/telemetry.ts | 58 +++++ packages/sdk/src/tool-cache.ts | 7 + packages/sdk/src/version.ts | 2 + scripts/sync-version.mjs | 44 ++++ 41 files changed, 925 insertions(+), 17 deletions(-) create mode 100644 .changeset/olive-donuts-shave.md create mode 100644 packages/ai-sdk/src/telemetry.test.ts create mode 100644 packages/ai-sdk/src/telemetry.ts create mode 100644 packages/ai-sdk/src/version.ts create mode 100644 packages/eve-extension/extension/lib/version.ts create mode 100644 packages/eve-extension/test/telemetry.test.ts create mode 100644 packages/eve/src/telemetry.test.ts create mode 100644 packages/eve/src/telemetry.ts create mode 100644 packages/eve/src/version.ts create mode 100644 packages/sdk/src/telemetry.test.ts create mode 100644 packages/sdk/src/telemetry.ts create mode 100644 packages/sdk/src/version.ts create mode 100644 scripts/sync-version.mjs diff --git a/.changeset/olive-donuts-shave.md b/.changeset/olive-donuts-shave.md new file mode 100644 index 0000000..aa67ddb --- /dev/null +++ b/.changeset/olive-donuts-shave.md @@ -0,0 +1,15 @@ +--- +"@upstash/agentkit-sdk": minor +"@upstash/agentkit-ai-sdk": minor +"@upstash/agentkit-eve": minor +"@upstash/agentkit-eve-extension": minor +--- + +feat: report the sdk name + version to Upstash via the redis client's telemetry headers + +Every feature that takes a `redis` client now appends its package tag to the client's +`Upstash-Telemetry-Sdk` header (e.g. +`@upstash/redis@1.38.0,@upstash/agentkit-sdk@0.2.0,@upstash/agentkit-ai-sdk@0.2.0`), matching +`@upstash/ratelimit`. No personal data, keys or identifiers are collected. Opt out with +`enableTelemetry: false` on any config, with the same option on the redis client, or with the +`UPSTASH_DISABLE_TELEMETRY` env var. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4115959..a052dda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,11 @@ jobs: - name: Typecheck run: pnpm typecheck + # Read-only: the telemetry VERSION constants are written by `pnpm ci:version` at release time, + # so this only fails if a version was bumped without it. + - name: Check version constants + run: node scripts/sync-version.mjs --check + - name: Build packages run: pnpm build diff --git a/CLAUDE.md b/CLAUDE.md index 4e0e24c..3e6ba08 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,6 +187,32 @@ and `eve-extension-demo` (a minimal eve scaffold that mounts the extension). `withIndex` helper is gone.) - Key naming: `agentkit:rateLimit:`, `agentkit:toolCache:::`, `agentkit:memory::`, `agentkit:chat::` (default prefixes shown). +- **Telemetry** (mirrors `@upstash/ratelimit`): every feature that takes a `redis` client tags it via the + client's hidden `addTelemetry` (protected in `@upstash/redis`, so typed structurally), appending to the + `Upstash-Telemetry-Sdk` header — e.g. + `@upstash/redis@1.38.0,@upstash/agentkit-sdk@0.2.0,@upstash/agentkit-ai-sdk@0.2.0`. Core + `packages/sdk/src/telemetry.ts` exports `addTelemetry(redis, { sdk?, enabled? })` + `SDK_TELEMETRY` + (both re-exported from the package root); each adapter has its own thin `src/telemetry.ts` passing its + package tag, so a client carries **both** the core and adapter tags. Dedup is a + `WeakMap>` — one tag per (client, sdk) pair, since the client *appends* on every call. + Opt out: `enableTelemetry: false` on any config (threaded down into the core primitive **and** its + `ReactiveSearchIndex`), the redis client's own `enableTelemetry`, or `UPSTASH_DISABLE_TELEMETRY`. + **Testing the header is wire-level, not mock-level:** `@upstash/redis` calls the *global* `fetch`, so + the `telemetry.test.ts` suites stub `globalThis.fetch`, point a client at a fake URL + (`responseEncoding: false`, `retry: false`, **`enableAutoPipelining: false`** — auto-pipelining sends a + batch and expects an *array* body, which breaks a naive stub) and assert on the captured + `Upstash-Telemetry-Sdk` header; the sdk suite also runs one live-Redis case proving a tagged request is + still accepted. The eve-extension suite (`packages/eve-extension/test/`) binds mount config the way eve + does — `globalThis[Symbol.for("eve.ext-config-scope")] = "agentkit"` while importing + `extension/extension.ts`, then call the mount factory — and tests the **source**, not `dist/`. + Failures are swallowed — telemetry must never break the client. **Version constants:** each package has a + committed `version.ts` (`packages/*/src`, extension: `extension/lib/`) written by + `scripts/sync-version.mjs`, which runs **only at release time** from root `ci:version` + (`changeset version && node scripts/sync-version.mjs`) so the constant lands in the release PR next to + the package.json bump. **`build`/`dev` must never regenerate it** — no build step may rewrite tracked + source (dirty worktrees, watch-mode churn). CI runs the read-only `--check` mode to catch drift. Note the + installed `@upstash/ratelimit@2.0.8` has **no** `enableTelemetry` option yet — don't pass one to + `new Ratelimit()`. ## AI SDK version strategy — IMPORTANT - **AI SDK v7 stable everywhere.** Every package + demo pins `ai` to exactly **`7.0.58`**. `eve` (0.32) diff --git a/README.md b/README.md index abf0961..ccc0ebf 100644 --- a/README.md +++ b/README.md @@ -49,13 +49,21 @@ pnpm typecheck # tsc across packages Tests need `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN` (in a repo-root `.env`); suites that hit Redis skip themselves when absent. Some tests use `UPSTASH_BOX_API_KEY` and `OPENAI_API_KEY`. +## Telemetry + +Each package reports its name and version to Upstash as a header on the requests made by your redis +client (e.g. `@upstash/redis@1.38.0,@upstash/agentkit-sdk@0.2.0,@upstash/agentkit-ai-sdk@0.2.0`), so we +know which SDK versions are in use. No personal data, keys or identifiers are collected. Opt out with +`enableTelemetry: false` on any feature config, with the same option on the redis client, or by setting +the `UPSTASH_DISABLE_TELEMETRY` environment variable. + ## Releasing This repo uses [Changesets](https://github.com/changesets/changesets). ```bash pnpm changeset # describe a change -pnpm ci:version # bump versions + changelogs +pnpm ci:version # bump versions + changelogs (also stamps each package's telemetry VERSION constant) pnpm ci:publish # publish to npm # (`version`/`release` script names are avoided — they collide with pnpm's built-in commands.) diff --git a/eslint.config.js b/eslint.config.js index c4697fd..181927d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -53,6 +53,15 @@ export default [ "@typescript-eslint/consistent-type-imports": "error", }, }, + { + // Build scripts run under Node. + files: ["scripts/**/*.mjs"], + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: { process: "readonly", console: "readonly" }, + }, + }, { files: ["**/*.test.ts", "**/test/**/*.ts"], rules: { diff --git a/package.json b/package.json index 8f9cec5..076cd55 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "typecheck": "pnpm --filter @upstash/agentkit-sdk build && pnpm -r --filter \"./packages/*\" typecheck", "clean": "pnpm -r --filter \"./packages/*\" clean", "changeset": "changeset", - "ci:version": "changeset version", + "ci:version": "changeset version && node scripts/sync-version.mjs", "ci:publish": "changeset publish" }, "devDependencies": { diff --git a/packages/ai-sdk/README.md b/packages/ai-sdk/README.md index 6aca66f..03d28eb 100644 --- a/packages/ai-sdk/README.md +++ b/packages/ai-sdk/README.md @@ -206,6 +206,21 @@ so you never pass a name yourself. +## Telemetry + +The SDK reports its name and version to Upstash as a header on the requests made by the redis client, +so we know which SDK versions are in use. No personal data, keys or identifiers are collected. The +header looks like `@upstash/redis@1.38.0,@upstash/agentkit-sdk@0.2.0,@upstash/agentkit-ai-sdk@0.2.0`. + +Opt out with `enableTelemetry: false` on any helper: + +```ts +const tools = createMemoryTools({ userId, enableTelemetry: false }); +``` + +or by setting the `UPSTASH_DISABLE_TELEMETRY` environment variable. Disabling telemetry on the redis +client itself also disables it here. + ## Testing Tests run against a **real Upstash Redis** (only LLM calls are mocked). Set `UPSTASH_REDIS_REST_URL` / diff --git a/packages/ai-sdk/src/chat-history.ts b/packages/ai-sdk/src/chat-history.ts index daf2746..c920d18 100644 --- a/packages/ai-sdk/src/chat-history.ts +++ b/packages/ai-sdk/src/chat-history.ts @@ -1,6 +1,7 @@ import type { UIMessage } from "ai"; import { ChatHistory } from "@upstash/agentkit-sdk"; import { Redis } from "@upstash/redis"; +import { addTelemetry } from "./telemetry.js"; export interface CreateChatHistoryConfig { /** Upstash Redis client. Defaults to `Redis.fromEnv()`. */ @@ -11,6 +12,11 @@ export interface CreateChatHistoryConfig { indexName?: string; /** Optional TTL (seconds) per chat. Omit for no expiry. */ ttlSeconds?: number; + /** + * Report the sdk name + version to Upstash as a header on the requests made by your redis client. + * Can also be disabled with the `UPSTASH_DISABLE_TELEMETRY` env var. Defaults to `true`. + */ + enableTelemetry?: boolean; } /** @@ -42,5 +48,7 @@ export interface CreateChatHistoryConfig { */ export function createChatHistory(config: CreateChatHistoryConfig = {}): ChatHistory { const { redis, ...rest } = config; - return new ChatHistory({ redis: redis ?? Redis.fromEnv(), ...rest }); + const client = redis ?? Redis.fromEnv(); + addTelemetry(client, rest.enableTelemetry); + return new ChatHistory({ redis: client, ...rest }); } diff --git a/packages/ai-sdk/src/memory.ts b/packages/ai-sdk/src/memory.ts index 1206e8e..1f664c7 100644 --- a/packages/ai-sdk/src/memory.ts +++ b/packages/ai-sdk/src/memory.ts @@ -2,6 +2,7 @@ import { tool, type ToolExecutionOptions, type ToolSet } from "ai"; import { z } from "zod"; import { AgentMemory } from "@upstash/agentkit-sdk"; import { Redis } from "@upstash/redis"; +import { addTelemetry } from "./telemetry.js"; /** * The user the memory is read/written under. A string shares all memory across callers (fine for a @@ -25,6 +26,11 @@ export interface CreateMemoryToolsConfig { recallToolName?: string; /** Override the save tool's key/name. Defaults to `save_memory`. */ saveToolName?: string; + /** + * Report the sdk name + version to Upstash as a header on the requests made by your redis client. + * Can also be disabled with the `UPSTASH_DISABLE_TELEMETRY` env var. Defaults to `true`. + */ + enableTelemetry?: boolean; } /** @@ -38,8 +44,13 @@ export interface CreateMemoryToolsConfig { * ``` */ export function createMemoryTools(config: CreateMemoryToolsConfig): ToolSet { - const { userId, topK, minScore } = config; - const memory = new AgentMemory({ redis: config.redis ?? Redis.fromEnv() }); + const { userId, topK, minScore, enableTelemetry } = config; + const redis = config.redis ?? Redis.fromEnv(); + addTelemetry(redis, enableTelemetry); + const memory = new AgentMemory({ + redis, + ...(enableTelemetry !== undefined ? { enableTelemetry } : {}), + }); const recallName = config.recallToolName ?? "recall_memory"; const saveName = config.saveToolName ?? "save_memory"; diff --git a/packages/ai-sdk/src/search-tools.ts b/packages/ai-sdk/src/search-tools.ts index e509f1a..a18f4a5 100644 --- a/packages/ai-sdk/src/search-tools.ts +++ b/packages/ai-sdk/src/search-tools.ts @@ -5,6 +5,7 @@ import { type SearchToolDef, type SearchToolDefsConfig, } from "@upstash/agentkit-sdk"; +import { addTelemetry } from "./telemetry.js"; export interface CreateSearchToolsConfig extends Omit { /** Upstash Redis client. Defaults to `Redis.fromEnv()`. */ @@ -42,6 +43,8 @@ function wrap(def: SearchToolDef): Tool { */ export function createSearchTools(config: CreateSearchToolsConfig): ToolSet { const { redis, ...rest } = config; - const defs = createSearchToolDefs({ redis: redis ?? Redis.fromEnv(), ...rest }); + const client = redis ?? Redis.fromEnv(); + addTelemetry(client, rest.enableTelemetry); + const defs = createSearchToolDefs({ redis: client, ...rest }); return { search: wrap(defs.search), aggregate: wrap(defs.aggregate), count: wrap(defs.count) }; } diff --git a/packages/ai-sdk/src/telemetry.test.ts b/packages/ai-sdk/src/telemetry.test.ts new file mode 100644 index 0000000..c8a9678 --- /dev/null +++ b/packages/ai-sdk/src/telemetry.test.ts @@ -0,0 +1,115 @@ +import { tool } from "ai"; +import { z } from "zod"; +import { afterEach, describe, expect, test } from "vitest"; +import { SDK_TELEMETRY } from "@upstash/agentkit-sdk"; +import { Redis } from "@upstash/redis"; +import { cachedTools } from "./tools.js"; +import { createMemoryTools } from "./memory.js"; +import { AI_SDK_TELEMETRY, addTelemetry } from "./telemetry.js"; +import { VERSION } from "./version.js"; + +/** A stand-in for the redis client: only `addTelemetry` (+ `search.index`) is exercised here. */ +const createRedisMock = () => { + const calls: { sdk?: string }[] = []; + return { + calls, + client: { + addTelemetry: (telemetry: { sdk?: string }) => { + calls.push(telemetry); + }, + search: { index: () => ({}) }, + }, + }; +}; + +describe("telemetry", () => { + test("reports this package's name and version", () => { + const { client, calls } = createRedisMock(); + addTelemetry(client); + + expect(AI_SDK_TELEMETRY).toBe(`@upstash/agentkit-ai-sdk@${VERSION}`); + expect(calls).toEqual([{ sdk: AI_SDK_TELEMETRY }]); + }); + + test("cachedTools tags the client with both the adapter and the core sdk", () => { + const { client, calls } = createRedisMock(); + cachedTools( + { + getWeather: tool({ + description: "Get the weather for a city", + inputSchema: z.object({ city: z.string() }), + execute: async ({ city }) => ({ city, temperature: 21 }), + }), + }, + { userId: "user-1", redis: client as never }, + ); + + expect(calls.map((c) => c.sdk)).toEqual([AI_SDK_TELEMETRY, SDK_TELEMETRY]); + }); + + test("createMemoryTools respects enableTelemetry: false", () => { + const { client, calls } = createRedisMock(); + createMemoryTools({ userId: "user-1", redis: client as never, enableTelemetry: false }); + + expect(calls.length).toBe(0); + }); +}); + +/** + * Proof the tags ride on the wire, not just that the client was told about them: the Upstash client + * calls the global `fetch`, so stubbing it captures the real outgoing request headers. + */ +describe("outgoing request headers", () => { + const realFetch = globalThis.fetch; + let sent: Record[] = []; + + function spyOnFetch(): void { + sent = []; + globalThis.fetch = (async (_url: unknown, init?: { headers?: Record }) => { + sent.push({ ...init?.headers }); + return new Response(JSON.stringify({ result: "OK" }), { status: 200 }); + }) as unknown as typeof fetch; + } + + /** A client pointed at nowhere, one request per command (no auto-pipelining) for the stub above. */ + function stubbedRedis(): Redis { + return new Redis({ + url: "https://telemetry.test.upstash.io", + token: "test-token", + responseEncoding: false, + retry: false, + enableAutoPipelining: false, + }); + } + + const telemetryHeader = (): string[] => + (sent[0]?.["Upstash-Telemetry-Sdk"] ?? "").split(",").filter(Boolean); + + afterEach(() => { + globalThis.fetch = realFetch; + }); + + test("a command carries both the adapter and the core tag", async () => { + spyOnFetch(); + const redis = stubbedRedis(); + createMemoryTools({ userId: "user-1", redis }); + + await redis.set("agentkit:telemetry-test", "value"); + + expect(sent.length).toBe(1); + expect(telemetryHeader()).toContain(AI_SDK_TELEMETRY); + expect(telemetryHeader()).toContain(SDK_TELEMETRY); + expect(telemetryHeader()[0]).toMatch(/^@upstash\/redis@/); + }); + + test("enableTelemetry: false keeps every agentkit tag off the request", async () => { + spyOnFetch(); + const redis = stubbedRedis(); + createMemoryTools({ userId: "user-1", redis, enableTelemetry: false }); + + await redis.set("agentkit:telemetry-test", "value"); + + expect(sent.length).toBe(1); + expect(telemetryHeader().some((tag) => tag.includes("agentkit"))).toBe(false); + }); +}); diff --git a/packages/ai-sdk/src/telemetry.ts b/packages/ai-sdk/src/telemetry.ts new file mode 100644 index 0000000..f06f149 --- /dev/null +++ b/packages/ai-sdk/src/telemetry.ts @@ -0,0 +1,15 @@ +import { addTelemetry as tagClient } from "@upstash/agentkit-sdk"; +import { VERSION } from "./version.js"; + +/** The telemetry tag of this package, appended to the redis client's `Upstash-Telemetry-Sdk` header. */ +export const AI_SDK_TELEMETRY = `@upstash/agentkit-ai-sdk@${VERSION}`; + +/** + * Tag the redis client with this adapter's sdk name + version. The core primitives built underneath + * add their own `@upstash/agentkit-sdk` tag, so the header reports both layers. Each client is + * tagged once per sdk name; opt out with `enableTelemetry: false`, with the same option on the redis + * client, or with the `UPSTASH_DISABLE_TELEMETRY` env var. + */ +export function addTelemetry(redis: unknown, enableTelemetry?: boolean): void { + tagClient(redis, { sdk: AI_SDK_TELEMETRY, enabled: enableTelemetry }); +} diff --git a/packages/ai-sdk/src/tools.ts b/packages/ai-sdk/src/tools.ts index e1bc4e2..477eb88 100644 --- a/packages/ai-sdk/src/tools.ts +++ b/packages/ai-sdk/src/tools.ts @@ -1,6 +1,7 @@ import { tool, type Tool, type ToolExecutionOptions, type ToolSet } from "ai"; import { ToolCache } from "@upstash/agentkit-sdk"; import { Redis } from "@upstash/redis"; +import { addTelemetry } from "./telemetry.js"; /** The user a cache entry is scoped to: a fixed string, or a function of the tool input + options. */ export type CacheUserId = @@ -14,6 +15,11 @@ export interface CachedToolsOptions { redis?: Redis; /** Default per-result TTL (seconds) for every cached tool. */ ttlSeconds?: number; + /** + * Report the sdk name + version to Upstash as a header on the requests made by your redis client. + * Can also be disabled with the `UPSTASH_DISABLE_TELEMETRY` env var. Defaults to `true`. + */ + enableTelemetry?: boolean; } /** Wrap an already-built `Tool`'s `execute` with caching, keyed by `userId` + `toolName` + hash. */ @@ -66,7 +72,13 @@ function wrapBuiltTool( * ``` */ export function cachedTools(tools: T, options: CachedToolsOptions): T { - const cache = new ToolCache({ redis: options.redis ?? Redis.fromEnv() }); + const { enableTelemetry } = options; + const redis = options.redis ?? Redis.fromEnv(); + addTelemetry(redis, enableTelemetry); + const cache = new ToolCache({ + redis, + ...(enableTelemetry !== undefined ? { enableTelemetry } : {}), + }); const out = {} as Record; for (const [name, built] of Object.entries(tools)) { out[name] = wrapBuiltTool(cache, name, options.userId, options.ttlSeconds, built); diff --git a/packages/ai-sdk/src/version.ts b/packages/ai-sdk/src/version.ts new file mode 100644 index 0000000..735615a --- /dev/null +++ b/packages/ai-sdk/src/version.ts @@ -0,0 +1,2 @@ +// Generated by scripts/sync-version.mjs (run by `pnpm ci:version`) — do not edit by hand. +export const VERSION = "0.2.0"; diff --git a/packages/eve-extension/README.md b/packages/eve-extension/README.md index 760917b..0f99bc6 100644 --- a/packages/eve-extension/README.md +++ b/packages/eve-extension/README.md @@ -163,6 +163,21 @@ Box **sandbox backend** and the **rate-limit channel auth** (extensions can't co channel config) — or its `defineCachedTool` wrapper for your own tools. The two compose fine in one agent. +## Telemetry + +The SDK reports its name and version to Upstash as a header on the requests made by the redis client, +so we know which SDK versions are in use. No personal data, keys or identifiers are collected. The +header looks like `@upstash/redis@1.38.0,@upstash/agentkit-sdk@0.2.0,@upstash/agentkit-eve-extension@0.5.0`. + +Opt out with `enableTelemetry: false` in the mount config: + +```ts +export default agentkit({ enableTelemetry: false }); +``` + +or by setting the `UPSTASH_DISABLE_TELEMETRY` environment variable. Disabling telemetry on the redis +client itself also disables it here. + ## Example [`examples/eve-extension-demo`](../../examples/eve-extension-demo) is a scaffolded eve agent with the diff --git a/packages/eve-extension/extension/extension.ts b/packages/eve-extension/extension/extension.ts index 8f7cae9..db06459 100644 --- a/packages/eve-extension/extension/extension.ts +++ b/packages/eve-extension/extension/extension.ts @@ -21,6 +21,11 @@ export default defineExtension({ userId: userId.optional(), /** Upstash Redis client. Defaults to `Redis.fromEnv()` (`UPSTASH_REDIS_REST_URL`/`_TOKEN`). */ redis: z.custom((value) => typeof value === "object" && value !== null).optional(), + /** + * Report the sdk name + version to Upstash as a header on the requests made by the redis client. + * Can also be disabled with the `UPSTASH_DISABLE_TELEMETRY` env var. Defaults to `true`. + */ + enableTelemetry: z.boolean().optional(), /** Knobs for the `recall_memory` tool. */ memory: z .object({ diff --git a/packages/eve-extension/extension/lib/runtime.ts b/packages/eve-extension/extension/lib/runtime.ts index b30b7c4..8c73742 100644 --- a/packages/eve-extension/extension/lib/runtime.ts +++ b/packages/eve-extension/extension/lib/runtime.ts @@ -1,12 +1,17 @@ import { AgentMemory, ChatHistory, + addTelemetry, createSearchToolDefs, type SearchToolDefs, } from "@upstash/agentkit-sdk"; import { Redis } from "@upstash/redis"; import type { SessionContext } from "eve/tools"; import extension from "../extension"; +import { VERSION } from "./version"; + +/** This package's telemetry tag; core adds its own `@upstash/agentkit-sdk` tag on the same client. */ +const EXTENSION_TELEMETRY = `@upstash/agentkit-eve-extension@${VERSION}`; /** * `userId` and `sessionId` become Redis key parts, and core key-part validation rejects `:` (the key @@ -28,7 +33,13 @@ export function resolveUserId(ctx: SessionContext): string { let redisClient: Redis | undefined; export function redis(): Redis { - return (redisClient ??= extension.config.redis ?? Redis.fromEnv()); + if (redisClient) return redisClient; + redisClient = extension.config.redis ?? Redis.fromEnv(); + addTelemetry(redisClient, { + sdk: EXTENSION_TELEMETRY, + enabled: extension.config.enableTelemetry, + }); + return redisClient; } let agentMemory: AgentMemory | undefined; diff --git a/packages/eve-extension/extension/lib/version.ts b/packages/eve-extension/extension/lib/version.ts new file mode 100644 index 0000000..a78d38c --- /dev/null +++ b/packages/eve-extension/extension/lib/version.ts @@ -0,0 +1,2 @@ +// Generated by scripts/sync-version.mjs (run by `pnpm ci:version`) — do not edit by hand. +export const VERSION = "0.5.0"; diff --git a/packages/eve-extension/test/telemetry.test.ts b/packages/eve-extension/test/telemetry.test.ts new file mode 100644 index 0000000..632f85c --- /dev/null +++ b/packages/eve-extension/test/telemetry.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeAll, describe, expect, test } from "vitest"; +import { SDK_TELEMETRY } from "@upstash/agentkit-sdk"; +import { Redis } from "@upstash/redis"; +import type * as Runtime from "../extension/lib/runtime"; + +/** + * The extension's redis client is built lazily inside `lib/runtime.ts` from the **mount config**, so + * this test binds config the way eve's runtime does — a string scope on `globalThis` while the + * extension module is evaluated, then the mount factory called with the config. Everything after + * that is the same wire-level check as the other packages: the Upstash client calls the global + * `fetch`, so stubbing it captures the headers actually sent. + */ +const EXT_CONFIG_SCOPE = Symbol.for("eve.ext-config-scope"); + +const realFetch = globalThis.fetch; +let sent: Record[] = []; + +function spyOnFetch(): void { + sent = []; + globalThis.fetch = (async (_url: unknown, init?: { headers?: Record }) => { + sent.push({ ...init?.headers }); + return new Response(JSON.stringify({ result: "OK" }), { status: 200 }); + }) as unknown as typeof fetch; +} + +/** A client pointed at nowhere, one request per command (no auto-pipelining) for the stub above. */ +const stubbedRedis = (): Redis => + new Redis({ + url: "https://telemetry.test.upstash.io", + token: "test-token", + responseEncoding: false, + retry: false, + enableAutoPipelining: false, + }); + +const telemetryHeader = (): string[] => + (sent[0]?.["Upstash-Telemetry-Sdk"] ?? "").split(",").filter(Boolean); + +describe("outgoing request headers", () => { + const redis = stubbedRedis(); + let runtime: typeof Runtime; + let version: string; + + beforeAll(async () => { + (globalThis as Record)[EXT_CONFIG_SCOPE] = "agentkit"; + const mount = (await import("../extension/extension")).default; + mount({ userId: "user-1", redis }); + (globalThis as Record)[EXT_CONFIG_SCOPE] = undefined; + + runtime = await import("../extension/lib/runtime"); + version = (await import("../extension/lib/version")).VERSION; + }); + + afterEach(() => { + globalThis.fetch = realFetch; + }); + + test("a command carries both the extension and the core tag", async () => { + spyOnFetch(); + // Touching the memory store is what builds the client and the core primitive on top of it. + runtime.memory(); + + await redis.set("agentkit:telemetry-test", "value"); + + expect(sent.length).toBe(1); + expect(telemetryHeader()).toContain(`@upstash/agentkit-eve-extension@${version}`); + expect(telemetryHeader()).toContain(SDK_TELEMETRY); + expect(telemetryHeader()[0]).toMatch(/^@upstash\/redis@/); + }); + + test("each tag is added only once, however many contributions run", async () => { + spyOnFetch(); + runtime.memory(); + runtime.memory(); + runtime.redis(); + + await redis.set("agentkit:telemetry-test", "value"); + + const extensionTag = `@upstash/agentkit-eve-extension@${version}`; + expect(telemetryHeader().filter((tag) => tag === extensionTag).length).toBe(1); + expect(telemetryHeader().filter((tag) => tag === SDK_TELEMETRY).length).toBe(1); + }); +}); diff --git a/packages/eve-extension/tsconfig.json b/packages/eve-extension/tsconfig.json index ba1682b..b1f79a7 100644 --- a/packages/eve-extension/tsconfig.json +++ b/packages/eve-extension/tsconfig.json @@ -9,5 +9,5 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["extension/**/*.ts"] + "include": ["extension/**/*.ts", "test/**/*.ts"] } diff --git a/packages/eve/README.md b/packages/eve/README.md index 0c653bc..a65fc67 100644 --- a/packages/eve/README.md +++ b/packages/eve/README.md @@ -296,6 +296,21 @@ does **not** include shared `agent/`-source modules (e.g. a `agent/lib/redis.ts` Shared app code (e.g. a seeder a page calls) lives in your project `lib/`, imported by the app — not by `agent/` files. +## Telemetry + +The SDK reports its name and version to Upstash as a header on the requests made by the redis client, +so we know which SDK versions are in use. No personal data, keys or identifiers are collected. The +header looks like `@upstash/redis@1.38.0,@upstash/agentkit-sdk@0.2.0,@upstash/agentkit-eve@0.5.0`. + +Opt out with `enableTelemetry: false` on any helper: + +```ts +export default defineMemoryRecallTool({ userId: (_, ctx) => ctx.session.id, enableTelemetry: false }); +``` + +or by setting the `UPSTASH_DISABLE_TELEMETRY` environment variable. Disabling telemetry on the redis +client itself also disables it here. + ## Testing Tests run against a **real Upstash Redis** (and a real Box when `UPSTASH_BOX_API_KEY` is set); only LLM diff --git a/packages/eve/src/auth.ts b/packages/eve/src/auth.ts index d76bbfa..5d5e8d5 100644 --- a/packages/eve/src/auth.ts +++ b/packages/eve/src/auth.ts @@ -1,6 +1,7 @@ import { createRateLimit, type RateLimitConfig } from "@upstash/agentkit-sdk"; import { Redis } from "@upstash/redis"; import { ForbiddenError, type AuthFn } from "eve/channels/auth"; +import { addTelemetry } from "./telemetry.js"; /** Configuration for {@link createRateLimitAuth}. */ export interface RateLimitAuthConfig extends Omit { @@ -50,7 +51,9 @@ export interface RateLimitAuthConfig extends Omit { */ export function createRateLimitAuth(config: RateLimitAuthConfig): AuthFn { const { identifier, message, redis, ...rest } = config; - const ratelimit = createRateLimit({ ...rest, redis: redis ?? Redis.fromEnv() }); + const client = redis ?? Redis.fromEnv(); + addTelemetry(client, rest.enableTelemetry); + const ratelimit = createRateLimit({ ...rest, redis: client }); return async (request) => { // Only throttle the model-invoking message submissions (POST). The follow-up `GET …/stream` (and diff --git a/packages/eve/src/memory.ts b/packages/eve/src/memory.ts index ed19e85..bb66422 100644 --- a/packages/eve/src/memory.ts +++ b/packages/eve/src/memory.ts @@ -3,6 +3,7 @@ import { AgentMemory } from "@upstash/agentkit-sdk"; import { Redis } from "@upstash/redis"; import { defineTool } from "eve/tools"; import type { ToolContext, ToolDefinition } from "eve/tools"; +import { addTelemetry } from "./telemetry.js"; /** * The user the memory is read/written under. A string shares all memory across callers (fine for a @@ -20,10 +21,20 @@ export interface MemoryToolConfig { topK?: number; /** Minimum relevance score for recall. */ minScore?: number; + /** + * Report the sdk name + version to Upstash as a header on the requests made by your redis client. + * Can also be disabled with the `UPSTASH_DISABLE_TELEMETRY` env var. Defaults to `true`. + */ + enableTelemetry?: boolean; } function resolveMemory(config: MemoryToolConfig): AgentMemory { - return new AgentMemory({ redis: config.redis ?? Redis.fromEnv() }); + const redis = config.redis ?? Redis.fromEnv(); + addTelemetry(redis, config.enableTelemetry); + return new AgentMemory({ + redis, + ...(config.enableTelemetry !== undefined ? { enableTelemetry: config.enableTelemetry } : {}), + }); } function resolveUserId(config: MemoryToolConfig, input: Record, ctx: ToolContext) { diff --git a/packages/eve/src/sandbox.ts b/packages/eve/src/sandbox.ts index 7a76df8..176756c 100644 --- a/packages/eve/src/sandbox.ts +++ b/packages/eve/src/sandbox.ts @@ -66,6 +66,7 @@ import type { SandboxSession, SandboxSessionUseFn, } from "eve/sandbox"; +import { addTelemetry } from "./telemetry.js"; /** Per-session (and per-bootstrap) options a caller can apply via `use(options)`. */ export interface UpstashSandboxOptions { @@ -99,6 +100,12 @@ export type UpstashBackendConfig = Omit & { redis?: Redis; /** Key prefix for the template registry. Defaults to `agentkit:sandbox:template`. */ templatePrefix?: string; + /** + * Report the sdk name + version to Upstash as a header on the requests made by the redis client + * backing the template registry. Can also be disabled with the `UPSTASH_DISABLE_TELEMETRY` env + * var. Defaults to `true`. + */ + enableTelemetry?: boolean; /** * A **base Box snapshot** every fresh session restores from, instead of a bare `Box.create`. Use it * to bake heavy, slow-changing setup (browser binaries, ffmpeg, a preinstalled toolchain) into one @@ -364,7 +371,10 @@ export class UpstashSandboxBackend implements SandboxBackend< /** Lazily resolve the Redis client backing the template registry (so envless setups that never use a * template don't trip `Redis.fromEnv()`). */ private redis(): Redis { - return (this.redisClient ??= this.config.redis ?? Redis.fromEnv()); + if (this.redisClient) return this.redisClient; + this.redisClient = this.config.redis ?? Redis.fromEnv(); + addTelemetry(this.redisClient, this.config.enableTelemetry); + return this.redisClient; } /** Registry key for a template's snapshot id, namespaced by backend name. */ diff --git a/packages/eve/src/search-tools.ts b/packages/eve/src/search-tools.ts index baa2145..dfa6b71 100644 --- a/packages/eve/src/search-tools.ts +++ b/packages/eve/src/search-tools.ts @@ -6,6 +6,7 @@ import { import { Redis } from "@upstash/redis"; import { defineTool } from "eve/tools"; import type { ToolDefinition } from "eve/tools"; +import { addTelemetry } from "./telemetry.js"; export interface DefineSearchToolsConfig extends Omit { /** Upstash Redis client. Defaults to `Redis.fromEnv()`. */ @@ -51,6 +52,8 @@ function wrap(def: SearchToolDef): ToolDefinition { */ export function defineSearchTools(config: DefineSearchToolsConfig): SearchToolSet { const { redis, ...rest } = config; - const defs = createSearchToolDefs({ redis: redis ?? Redis.fromEnv(), ...rest }); + const client = redis ?? Redis.fromEnv(); + addTelemetry(client, rest.enableTelemetry); + const defs = createSearchToolDefs({ redis: client, ...rest }); return { search: wrap(defs.search), aggregate: wrap(defs.aggregate), count: wrap(defs.count) }; } diff --git a/packages/eve/src/telemetry.test.ts b/packages/eve/src/telemetry.test.ts new file mode 100644 index 0000000..2374439 --- /dev/null +++ b/packages/eve/src/telemetry.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { SDK_TELEMETRY } from "@upstash/agentkit-sdk"; +import { Redis, s } from "@upstash/redis"; +import { defineMemoryRecallTool } from "./memory.js"; +import { EVE_TELEMETRY } from "./telemetry.js"; +import { VERSION } from "./version.js"; + +/** + * The Upstash client calls the global `fetch`, so stubbing it captures the headers actually put on + * the wire (no credentials and no network needed). + */ +describe("outgoing request headers", () => { + const realFetch = globalThis.fetch; + let sent: Record[] = []; + + function spyOnFetch(): void { + sent = []; + globalThis.fetch = (async (_url: unknown, init?: { headers?: Record }) => { + sent.push({ ...init?.headers }); + return new Response(JSON.stringify({ result: "OK" }), { status: 200 }); + }) as unknown as typeof fetch; + } + + /** A client pointed at nowhere, one request per command (no auto-pipelining) for the stub above. */ + function stubbedRedis(): Redis { + return new Redis({ + url: "https://telemetry.test.upstash.io", + token: "test-token", + responseEncoding: false, + retry: false, + enableAutoPipelining: false, + }); + } + + const telemetryHeader = (): string[] => + (sent[0]?.["Upstash-Telemetry-Sdk"] ?? "").split(",").filter(Boolean); + + afterEach(() => { + globalThis.fetch = realFetch; + }); + + test("reports this package's name and version", () => { + expect(EVE_TELEMETRY).toBe(`@upstash/agentkit-eve@${VERSION}`); + }); + + test("a command carries both the adapter and the core tag", async () => { + spyOnFetch(); + const redis = stubbedRedis(); + defineMemoryRecallTool({ userId: "user-1", redis }); + + await redis.set("agentkit:telemetry-test", "value"); + + expect(sent.length).toBe(1); + expect(telemetryHeader()).toContain(EVE_TELEMETRY); + expect(telemetryHeader()).toContain(SDK_TELEMETRY); + expect(telemetryHeader()[0]).toMatch(/^@upstash\/redis@/); + }); + + test("search tools tag the client too", async () => { + spyOnFetch(); + const redis = stubbedRedis(); + // Imported lazily so this file's other tests don't pay for the schema build. + const { defineSearchTools } = await import("./search-tools.js"); + defineSearchTools({ redis, schema: s.object({ title: s.string() }), indexName: "tel_test" }); + + await redis.set("agentkit:telemetry-test", "value"); + + expect(telemetryHeader()).toContain(EVE_TELEMETRY); + expect(telemetryHeader()).toContain(SDK_TELEMETRY); + }); + + test("enableTelemetry: false keeps every agentkit tag off the request", async () => { + spyOnFetch(); + const redis = stubbedRedis(); + defineMemoryRecallTool({ userId: "user-1", redis, enableTelemetry: false }); + + await redis.set("agentkit:telemetry-test", "value"); + + expect(sent.length).toBe(1); + expect(telemetryHeader().some((tag) => tag.includes("agentkit"))).toBe(false); + }); +}); diff --git a/packages/eve/src/telemetry.ts b/packages/eve/src/telemetry.ts new file mode 100644 index 0000000..9d37386 --- /dev/null +++ b/packages/eve/src/telemetry.ts @@ -0,0 +1,15 @@ +import { addTelemetry as tagClient } from "@upstash/agentkit-sdk"; +import { VERSION } from "./version.js"; + +/** The telemetry tag of this package, appended to the redis client's `Upstash-Telemetry-Sdk` header. */ +export const EVE_TELEMETRY = `@upstash/agentkit-eve@${VERSION}`; + +/** + * Tag the redis client with this adapter's sdk name + version. The core primitives built underneath + * add their own `@upstash/agentkit-sdk` tag, so the header reports both layers. Each client is + * tagged once per sdk name; opt out with `enableTelemetry: false`, with the same option on the redis + * client, or with the `UPSTASH_DISABLE_TELEMETRY` env var. + */ +export function addTelemetry(redis: unknown, enableTelemetry?: boolean): void { + tagClient(redis, { sdk: EVE_TELEMETRY, enabled: enableTelemetry }); +} diff --git a/packages/eve/src/tools.ts b/packages/eve/src/tools.ts index 21aeca2..9dbeda9 100644 --- a/packages/eve/src/tools.ts +++ b/packages/eve/src/tools.ts @@ -2,6 +2,7 @@ import { ToolCache } from "@upstash/agentkit-sdk"; import { Redis } from "@upstash/redis"; import { defineTool } from "eve/tools"; import type { ToolContext, ToolDefinition } from "eve/tools"; +import { addTelemetry } from "./telemetry.js"; /** The user a cache entry is scoped to: a fixed string, or a function of the tool input + context. */ export type CacheUserId = string | ((input: TInput, ctx: ToolContext) => string); @@ -31,6 +32,11 @@ export type DefineCachedToolConfig = Omit< userId: CacheUserId; /** Per-result TTL (seconds). */ ttlSeconds?: number; + /** + * Report the sdk name + version to Upstash as a header on the requests made by your redis client. + * Can also be disabled with the `UPSTASH_DISABLE_TELEMETRY` env var. Defaults to `true`. + */ + enableTelemetry?: boolean; }; /** @@ -56,8 +62,13 @@ export type DefineCachedToolConfig = Omit< export function defineCachedTool( config: DefineCachedToolConfig, ): ToolDefinition { - const { redis, toolName, userId, ttlSeconds, execute, ...rest } = config; - const cache = new ToolCache({ redis: redis ?? Redis.fromEnv() }); + const { redis, toolName, userId, ttlSeconds, enableTelemetry, execute, ...rest } = config; + const client = redis ?? Redis.fromEnv(); + addTelemetry(client, enableTelemetry); + const cache = new ToolCache({ + redis: client, + ...(enableTelemetry !== undefined ? { enableTelemetry } : {}), + }); return defineTool({ ...rest, diff --git a/packages/eve/src/version.ts b/packages/eve/src/version.ts new file mode 100644 index 0000000..a78d38c --- /dev/null +++ b/packages/eve/src/version.ts @@ -0,0 +1,2 @@ +// Generated by scripts/sync-version.mjs (run by `pnpm ci:version`) — do not edit by hand. +export const VERSION = "0.5.0"; diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 2dee936..a3681ac 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -201,6 +201,21 @@ await memory.searchIndex.waitIndexing(); const info = await memory.searchIndex.describe(); ``` +## Telemetry + +The SDK reports its name and version to Upstash as a header on the requests made by the redis client +you provide, so we know which SDK versions are in use. No personal data, keys or identifiers are +collected. The header looks like `@upstash/redis@1.38.0,@upstash/agentkit-sdk@0.2.0`. + +Opt out with `enableTelemetry: false` on any feature: + +```ts +const memory = new AgentMemory({ redis, enableTelemetry: false }); +``` + +or by setting the `UPSTASH_DISABLE_TELEMETRY` environment variable. Disabling telemetry on the redis +client itself also disables it here. + ## Testing Tested against a **real Upstash Redis** instance (no Redis mock); only LLM calls are mocked. Set diff --git a/packages/sdk/src/chat-history.ts b/packages/sdk/src/chat-history.ts index 7865f99..b3aa5d5 100644 --- a/packages/sdk/src/chat-history.ts +++ b/packages/sdk/src/chat-history.ts @@ -1,6 +1,7 @@ import { s } from "@upstash/redis"; import type { InferFilterFromSchema, Redis } from "@upstash/redis"; import { ReactiveSearchIndex } from "./reactive-index.js"; +import { addTelemetry } from "./telemetry.js"; import { now } from "./utils.js"; /** @@ -65,6 +66,11 @@ export interface ChatHistoryConfig { * convention (`{ role, parts: [{ type: "text", text }] }`), so adapters rarely override it. */ extractText?: (messages: TMessage[]) => ExtractedText; + /** + * Report the sdk name + version to Upstash as a header on the requests made by your redis client. + * Can also be disabled with the `UPSTASH_DISABLE_TELEMETRY` env var. Defaults to `true`. + */ + enableTelemetry?: boolean; } interface ChatDoc { @@ -135,6 +141,7 @@ export class ChatHistory { constructor(config: ChatHistoryConfig) { this.redis = config.redis; + addTelemetry(config.redis, { enabled: config.enableTelemetry }); const prefix = config.prefix ?? "agentkit:chat"; // Index names must be identifier-safe; the key prefix keeps the human-readable base prefix. const indexName = config.indexName ?? prefix.replace(/[^a-zA-Z0-9_]/g, "_"); @@ -144,6 +151,7 @@ export class ChatHistory { indexName, prefix: this.keyPrefix, schema: ChatHistorySchema, + ...(config.enableTelemetry !== undefined ? { enableTelemetry: config.enableTelemetry } : {}), }); this.ttlSeconds = config.ttlSeconds; this.extract = config.extractText ?? (defaultExtract as (m: TMessage[]) => ExtractedText); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 4a0ec99..fba1d3b 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,6 +1,12 @@ // Utilities export { key, now, stableHash, stableStringify } from "./utils.js"; +// Telemetry — tags the redis client's `Upstash-Telemetry-Sdk` header. Adapter packages call this +// with their own `sdk` tag; opt out per feature with `enableTelemetry: false`, on the redis client, +// or with the `UPSTASH_DISABLE_TELEMETRY` env var. +export { addTelemetry, SDK_TELEMETRY } from "./telemetry.js"; +export { VERSION } from "./version.js"; + // Reactive search index — provisions the Upstash index on first read; the type each feature's // `.searchIndex` getter returns. export { ReactiveSearchIndex } from "./reactive-index.js"; diff --git a/packages/sdk/src/memory.ts b/packages/sdk/src/memory.ts index 595a52d..7595f1b 100644 --- a/packages/sdk/src/memory.ts +++ b/packages/sdk/src/memory.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { s } from "@upstash/redis"; import type { InferFilterFromSchema, Redis } from "@upstash/redis"; import { ReactiveSearchIndex } from "./reactive-index.js"; +import { addTelemetry } from "./telemetry.js"; import { now } from "./utils.js"; /** @@ -38,6 +39,11 @@ export interface AgentMemoryConfig { indexName?: string; /** Default relevance floor for {@link AgentMemory.recall} (BM25 score). */ minScore?: number; + /** + * Report the sdk name + version to Upstash as a header on the requests made by your redis client. + * Can also be disabled with the `UPSTASH_DISABLE_TELEMETRY` env var. Defaults to `true`. + */ + enableTelemetry?: boolean; } /** One JSON doc per memory: `text` is fuzzy-searchable, `userId` is an exact-match tenant filter. */ @@ -62,6 +68,7 @@ export class AgentMemory { constructor(config: AgentMemoryConfig) { this.redis = config.redis; + addTelemetry(config.redis, { enabled: config.enableTelemetry }); const prefix = config.prefix ?? "agentkit:memory"; // Index names must be identifier-safe; the key prefix keeps the human-readable base prefix. const indexName = config.indexName ?? prefix.replace(/[^a-zA-Z0-9_]/g, "_"); @@ -71,6 +78,7 @@ export class AgentMemory { indexName, prefix: this.keyPrefix, schema: MemorySchema, + ...(config.enableTelemetry !== undefined ? { enableTelemetry: config.enableTelemetry } : {}), }); this.minScore = config.minScore ?? 0; } diff --git a/packages/sdk/src/rate-limit.ts b/packages/sdk/src/rate-limit.ts index a9990c0..fd67ab0 100644 --- a/packages/sdk/src/rate-limit.ts +++ b/packages/sdk/src/rate-limit.ts @@ -1,5 +1,6 @@ import { Ratelimit, type Duration } from "@upstash/ratelimit"; import { Redis } from "@upstash/redis"; +import { addTelemetry } from "./telemetry.js"; // Re-export the `@upstash/ratelimit` surface AgentKit users need so they never have to import from // (or install) `@upstash/ratelimit` directly. `Ratelimit` is the class whose static helpers build a @@ -18,6 +19,11 @@ export interface RateLimitConfig { limiter: Limiter; /** Key prefix for the limiter. Defaults to `agentkit:rateLimit`; keys are `:`. */ prefix?: string; + /** + * Report the sdk name + version to Upstash as a header on the requests made by your redis client. + * Can also be disabled with the `UPSTASH_DISABLE_TELEMETRY` env var. Defaults to `true`. + */ + enableTelemetry?: boolean; } /** @@ -32,8 +38,10 @@ export interface RateLimitConfig { * ``` */ export function createRateLimit(config: RateLimitConfig): Ratelimit { + const redis = config.redis ?? Redis.fromEnv(); + addTelemetry(redis, { enabled: config.enableTelemetry }); return new Ratelimit({ - redis: config.redis ?? Redis.fromEnv(), + redis, limiter: config.limiter, prefix: config.prefix ?? "agentkit:rateLimit", }); diff --git a/packages/sdk/src/reactive-index.ts b/packages/sdk/src/reactive-index.ts index 340de0c..b285fd6 100644 --- a/packages/sdk/src/reactive-index.ts +++ b/packages/sdk/src/reactive-index.ts @@ -1,4 +1,5 @@ import type { FlatIndexSchema, NestedIndexSchema, Redis, SearchIndex } from "@upstash/redis"; +import { addTelemetry } from "./telemetry.js"; /** The schema type accepted by `redis.search.index` / `createIndex` (an `s.object({...})`). */ export type AnySearchSchema = NestedIndexSchema | FlatIndexSchema; @@ -25,6 +26,11 @@ export interface ReactiveSearchIndexConfig { prefix: string; /** The Upstash Redis Search schema (an `s.object({...})`). */ schema: TSchema; + /** + * Report the sdk name + version to Upstash as a header on the requests made by your redis client. + * Can also be disabled with the `UPSTASH_DISABLE_TELEMETRY` env var. Defaults to `true`. + */ + enableTelemetry?: boolean; } /** @@ -47,6 +53,7 @@ export class ReactiveSearchIndex { constructor(config: ReactiveSearchIndexConfig) { this.redis = config.redis; + addTelemetry(config.redis, { enabled: config.enableTelemetry }); this.indexName = config.indexName; this.prefix = config.prefix; this.schema = config.schema; diff --git a/packages/sdk/src/search-tools.ts b/packages/sdk/src/search-tools.ts index 11aeea8..60fe32d 100644 --- a/packages/sdk/src/search-tools.ts +++ b/packages/sdk/src/search-tools.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { InferFilterFromSchema, Redis } from "@upstash/redis"; import { ReactiveSearchIndex, type AnySearchSchema } from "./reactive-index.js"; +import { addTelemetry } from "./telemetry.js"; export interface SearchToolDefsConfig { /** The Upstash Redis Search schema (built with `s` from `@upstash/redis`). */ @@ -13,6 +14,11 @@ export interface SearchToolDefsConfig, ): SearchToolDefs { const { redis, schema } = config; + addTelemetry(redis, { enabled: config.enableTelemetry }); const indexName = config.indexName ?? "agentkit:search"; const prefix = config.prefix ?? `${indexName}:`; const defaultLimit = config.defaultLimit ?? 10; // The index is provisioned reactively on first read; writes (your seeding) need no index. - const index = new ReactiveSearchIndex({ redis, indexName, prefix, schema }); + const index = new ReactiveSearchIndex({ + redis, + indexName, + prefix, + schema, + ...(config.enableTelemetry !== undefined ? { enableTelemetry: config.enableTelemetry } : {}), + }); // The filter/aggregation objects come from the model (untyped at runtime), so they're cast to the // index's real, schema-derived parameter types at the call sites below — not to `never`. type Filter = InferFilterFromSchema; diff --git a/packages/sdk/src/telemetry.test.ts b/packages/sdk/src/telemetry.test.ts new file mode 100644 index 0000000..e8d5cb3 --- /dev/null +++ b/packages/sdk/src/telemetry.test.ts @@ -0,0 +1,220 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { Redis } from "@upstash/redis"; +import { ChatHistory } from "./chat-history.js"; +import { AgentMemory } from "./memory.js"; +import { addTelemetry, SDK_TELEMETRY } from "./telemetry.js"; +import { hasRedisCreds, testRedis, uniquePrefix } from "./test-support.js"; +import { ToolCache } from "./tool-cache.js"; +import { VERSION } from "./version.js"; + +/** A stand-in for the redis client: only `addTelemetry` (+ `search.index`) is exercised here. */ +const createRedisMock = () => { + const calls: { sdk?: string }[] = []; + return { + calls, + client: { + addTelemetry: (telemetry: { sdk?: string }) => { + calls.push(telemetry); + }, + search: { index: () => ({}) }, + }, + }; +}; + +describe("addTelemetry", () => { + afterEach(() => { + delete process.env.UPSTASH_DISABLE_TELEMETRY; + }); + + test("sends the sdk name and version", () => { + const { client, calls } = createRedisMock(); + addTelemetry(client); + + expect(calls).toEqual([{ sdk: `@upstash/agentkit-sdk@${VERSION}` }]); + expect(SDK_TELEMETRY).toBe(`@upstash/agentkit-sdk@${VERSION}`); + }); + + test("tags a client only once per sdk", () => { + const { client, calls } = createRedisMock(); + addTelemetry(client); + addTelemetry(client); + + expect(calls.length).toBe(1); + }); + + test("tags the same client once per distinct sdk (core + adapter layers)", () => { + const { client, calls } = createRedisMock(); + addTelemetry(client); + addTelemetry(client, { sdk: "@upstash/agentkit-ai-sdk@1.2.3" }); + addTelemetry(client, { sdk: "@upstash/agentkit-ai-sdk@1.2.3" }); + + expect(calls).toEqual([{ sdk: SDK_TELEMETRY }, { sdk: "@upstash/agentkit-ai-sdk@1.2.3" }]); + }); + + test("respects enabled: false", () => { + const { client, calls } = createRedisMock(); + addTelemetry(client, { enabled: false }); + + expect(calls.length).toBe(0); + }); + + test("respects UPSTASH_DISABLE_TELEMETRY", () => { + process.env.UPSTASH_DISABLE_TELEMETRY = "1"; + const { client, calls } = createRedisMock(); + addTelemetry(client); + + expect(calls.length).toBe(0); + }); + + test("does not throw on clients without addTelemetry", () => { + expect(() => addTelemetry({})).not.toThrow(); + expect(() => addTelemetry(undefined)).not.toThrow(); + }); + + test("does not throw when addTelemetry throws", () => { + const client = { + addTelemetry: () => { + throw new Error("boom"); + }, + }; + + expect(() => addTelemetry(client)).not.toThrow(); + }); +}); + +describe("feature wiring", () => { + test("ToolCache tags its redis client", () => { + const { client, calls } = createRedisMock(); + new ToolCache({ redis: client as never }); + + expect(calls).toEqual([{ sdk: SDK_TELEMETRY }]); + }); + + test("ToolCache respects enableTelemetry: false", () => { + const { client, calls } = createRedisMock(); + new ToolCache({ redis: client as never, enableTelemetry: false }); + + expect(calls.length).toBe(0); + }); + + test("AgentMemory tags its redis client once (not again via its search index)", () => { + const { client, calls } = createRedisMock(); + new AgentMemory({ redis: client as never }); + + expect(calls).toEqual([{ sdk: SDK_TELEMETRY }]); + }); + + test("AgentMemory respects enableTelemetry: false", () => { + const { client, calls } = createRedisMock(); + new AgentMemory({ redis: client as never, enableTelemetry: false }); + + expect(calls.length).toBe(0); + }); +}); + +/** + * The tests above prove the client was *told* about the sdk. These prove the tag actually rides on + * the wire: the Upstash client calls the global `fetch`, so stubbing it captures the real outgoing + * request headers (no credentials and no network needed). + */ +describe("outgoing request headers", () => { + const realFetch = globalThis.fetch; + let sent: Record[] = []; + + /** Swap in a fetch that records request headers and replies like the Upstash REST API. */ + function spyOnFetch(): void { + sent = []; + globalThis.fetch = (async (_url: unknown, init?: { headers?: Record }) => { + sent.push({ ...init?.headers }); + return new Response(JSON.stringify({ result: "OK" }), { status: 200 }); + }) as unknown as typeof fetch; + } + + /** + * A client pointed at nowhere — every request is served by the fetch stub above. Auto-pipelining + * is off so one command is one request with one (non-array) response body to fake. + */ + function stubbedRedis(): Redis { + return new Redis({ + url: "https://telemetry.test.upstash.io", + token: "test-token", + responseEncoding: false, + retry: false, + enableAutoPipelining: false, + }); + } + + const telemetryHeader = (): string[] => + (sent[0]?.["Upstash-Telemetry-Sdk"] ?? "").split(",").filter(Boolean); + + afterEach(() => { + globalThis.fetch = realFetch; + }); + + test("a command carries the sdk tag alongside the redis client's own", async () => { + spyOnFetch(); + const redis = stubbedRedis(); + new AgentMemory({ redis }); + + await redis.set("agentkit:telemetry-test", "value"); + + expect(sent.length).toBe(1); + expect(telemetryHeader()).toContain(SDK_TELEMETRY); + // The redis client tags itself first; ours is appended, never replacing it. + expect(telemetryHeader()[0]).toMatch(/^@upstash\/redis@/); + }); + + test("every feature on one client contributes exactly one tag", async () => { + spyOnFetch(); + const redis = stubbedRedis(); + new AgentMemory({ redis }); + new ToolCache({ redis }); + new ChatHistory({ redis }); + + await redis.set("agentkit:telemetry-test", "value"); + + expect(telemetryHeader().filter((tag) => tag === SDK_TELEMETRY).length).toBe(1); + }); + + test("enableTelemetry: false keeps the tag off the request", async () => { + spyOnFetch(); + const redis = stubbedRedis(); + new AgentMemory({ redis, enableTelemetry: false }); + + await redis.set("agentkit:telemetry-test", "value"); + + expect(sent.length).toBe(1); + expect(telemetryHeader()).not.toContain(SDK_TELEMETRY); + expect(telemetryHeader().some((tag) => tag.includes("agentkit"))).toBe(false); + }); +}); + +/** The header must also be accepted by the real API — a tagged client still executes commands. */ +describe.skipIf(!hasRedisCreds)("outgoing request headers (live Redis)", () => { + const realFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = realFetch; + }); + + test("a real command is sent with the tag and still succeeds", async () => { + const sent: string[] = []; + globalThis.fetch = ((url: never, init?: { headers?: Record }) => { + const header = init?.headers?.["Upstash-Telemetry-Sdk"]; + if (header) sent.push(header); + return realFetch(url, init as never); + }) as unknown as typeof fetch; + + const redis = testRedis(); + const key = `${uniquePrefix("telemetry")}:probe`; + new AgentMemory({ redis }); + + await redis.set(key, "ok", { ex: 30 }); + const value = await redis.get(key); + await redis.del(key); + + expect(value).toBe("ok"); + expect(sent.length).toBeGreaterThan(0); + expect(sent.every((header) => header.split(",").includes(SDK_TELEMETRY))).toBe(true); + }); +}); diff --git a/packages/sdk/src/telemetry.ts b/packages/sdk/src/telemetry.ts new file mode 100644 index 0000000..93496bb --- /dev/null +++ b/packages/sdk/src/telemetry.ts @@ -0,0 +1,58 @@ +import { VERSION } from "./version.js"; + +/** + * Minimal shape of the redis client we need for telemetry. `addTelemetry` is `protected` in + * `@upstash/redis`, so it is not part of the public types. + */ +type TelemetryCapableRedis = { + addTelemetry?: (telemetry: { sdk?: string; platform?: string; runtime?: string }) => void; +}; + +/** The telemetry tag of this (core) package. Adapters pass their own package tag instead. */ +export const SDK_TELEMETRY = `@upstash/agentkit-sdk@${VERSION}`; + +/** + * The redis client *appends* to the telemetry header on every `addTelemetry` call, so each client is + * tagged once per sdk name no matter how many primitives are built on it. The set is per client + * because one process can hold several clients; the sdk name is part of the key because a core + * primitive and the adapter that wrapped it both tag the same client with different names. + */ +const taggedClients = new WeakMap>(); + +const getSafeEnv = (): Record => + typeof process === "object" && process && typeof process.env === "object" ? process.env : {}; + +/** + * Reports the sdk name and version to Upstash through the redis client's telemetry headers. The + * redis client already reports the platform and the runtime, so we only append our own sdk tag, + * resulting in a header like + * `@upstash/redis@1.38.0,@upstash/agentkit-sdk@0.2.0,@upstash/agentkit-ai-sdk@0.2.0`. + * + * Opt out with `enableTelemetry: false` on any AgentKit config, with the same option on the redis + * client itself, or with the `UPSTASH_DISABLE_TELEMETRY` env var. + */ +export const addTelemetry = ( + redis: unknown, + options: { + /** The sdk tag to report. Defaults to {@link SDK_TELEMETRY} (this package). */ + sdk?: string; + /** Set `false` to skip reporting. Defaults to `true`. */ + enabled?: boolean; + } = {}, +): void => { + const { sdk = SDK_TELEMETRY, enabled = true } = options; + if (!enabled || getSafeEnv().UPSTASH_DISABLE_TELEMETRY) return; + if (!redis || typeof redis !== "object") return; + + let tags = taggedClients.get(redis); + if (!tags) taggedClients.set(redis, (tags = new Set())); + if (tags.has(sdk)) return; + tags.add(sdk); + + try { + // addTelemetry is intentionally hidden from the public types of @upstash/redis + (redis as TelemetryCapableRedis).addTelemetry?.({ sdk }); + } catch { + // telemetry must never break the client + } +}; diff --git a/packages/sdk/src/tool-cache.ts b/packages/sdk/src/tool-cache.ts index b382471..12b29cd 100644 --- a/packages/sdk/src/tool-cache.ts +++ b/packages/sdk/src/tool-cache.ts @@ -1,4 +1,5 @@ import type { Redis } from "@upstash/redis"; +import { addTelemetry } from "./telemetry.js"; import { key, stableHash } from "./utils.js"; /** @@ -22,6 +23,11 @@ export interface ToolCacheConfig { prefix?: string; /** Default TTL (seconds) for cached results. Omit for no expiry. */ ttlSeconds?: number; + /** + * Report the sdk name + version to Upstash as a header on the requests made by your redis client. + * Can also be disabled with the `UPSTASH_DISABLE_TELEMETRY` env var. Defaults to `true`. + */ + enableTelemetry?: boolean; } /** A cached tool result. `null`/`undefined` results are cached too, hence the wrapper object. */ @@ -41,6 +47,7 @@ export class ToolCache { constructor(config: ToolCacheConfig) { this.redis = config.redis; + addTelemetry(config.redis, { enabled: config.enableTelemetry }); this.prefix = config.prefix ?? "agentkit:toolCache"; this.ttlSeconds = config.ttlSeconds; } diff --git a/packages/sdk/src/version.ts b/packages/sdk/src/version.ts new file mode 100644 index 0000000..735615a --- /dev/null +++ b/packages/sdk/src/version.ts @@ -0,0 +1,2 @@ +// Generated by scripts/sync-version.mjs (run by `pnpm ci:version`) — do not edit by hand. +export const VERSION = "0.2.0"; diff --git a/scripts/sync-version.mjs b/scripts/sync-version.mjs new file mode 100644 index 0000000..1136177 --- /dev/null +++ b/scripts/sync-version.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +// Stamps each package's own version into its `version.ts` constant, so the telemetry header reports +// the real published version. +// +// This runs at **release time only** (`pnpm ci:version`, right after `changeset version` bumps the +// package.json files), so the constant lands in the release PR alongside the version bump. Builds and +// dev never rewrite tracked source. +// +// node scripts/sync-version.mjs # write +// node scripts/sync-version.mjs --check # verify only, non-zero exit on drift (used by CI) +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Package dir → the generated constant's path within it. +const TARGETS = { + "packages/sdk": "src/version.ts", + "packages/ai-sdk": "src/version.ts", + "packages/eve": "src/version.ts", + "packages/eve-extension": "extension/lib/version.ts", +}; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const check = process.argv.includes("--check"); +const stale = []; + +for (const [pkgDir, target] of Object.entries(TARGETS)) { + const { version } = JSON.parse(readFileSync(join(root, pkgDir, "package.json"), "utf8")); + const path = join(root, pkgDir, target); + const contents = `// Generated by scripts/sync-version.mjs (run by \`pnpm ci:version\`) — do not edit by hand. +export const VERSION = "${version}"; +`; + + if (readFileSync(path, "utf8") === contents) continue; + if (check) stale.push(`${pkgDir}/${target} (expected ${version})`); + else writeFileSync(path, contents); +} + +if (check && stale.length > 0) { + console.error( + `Stale version constants — run \`node scripts/sync-version.mjs\`:\n ${stale.join("\n ")}`, + ); + process.exit(1); +}