Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/olive-donuts-shave.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 26 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,32 @@ and `eve-extension-demo` (a minimal eve scaffold that mounts the extension).
`withIndex` helper is gone.)
- Key naming: `agentkit:rateLimit:<identifier>`, `agentkit:toolCache:<userId>:<toolName>:<hash>`,
`agentkit:memory:<userId>:<id>`, `agentkit:chat:<userId>:<sessionId>` (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<client, Set<sdk>>` — 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)
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down
9 changes: 9 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
15 changes: 15 additions & 0 deletions packages/ai-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,21 @@ so you never pass a name yourself.

</details>

## 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` /
Expand Down
10 changes: 9 additions & 1 deletion packages/ai-sdk/src/chat-history.ts
Original file line number Diff line number Diff line change
@@ -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()`. */
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -42,5 +48,7 @@ export interface CreateChatHistoryConfig {
*/
export function createChatHistory(config: CreateChatHistoryConfig = {}): ChatHistory<UIMessage> {
const { redis, ...rest } = config;
return new ChatHistory<UIMessage>({ redis: redis ?? Redis.fromEnv(), ...rest });
const client = redis ?? Redis.fromEnv();
addTelemetry(client, rest.enableTelemetry);
return new ChatHistory<UIMessage>({ redis: client, ...rest });
}
15 changes: 13 additions & 2 deletions packages/ai-sdk/src/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}

/**
Expand All @@ -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";

Expand Down
5 changes: 4 additions & 1 deletion packages/ai-sdk/src/search-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type SearchToolDef,
type SearchToolDefsConfig,
} from "@upstash/agentkit-sdk";
import { addTelemetry } from "./telemetry.js";

export interface CreateSearchToolsConfig extends Omit<SearchToolDefsConfig, "redis"> {
/** Upstash Redis client. Defaults to `Redis.fromEnv()`. */
Expand Down Expand Up @@ -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) };
}
115 changes: 115 additions & 0 deletions packages/ai-sdk/src/telemetry.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>[] = [];

function spyOnFetch(): void {
sent = [];
globalThis.fetch = (async (_url: unknown, init?: { headers?: Record<string, string> }) => {
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);
});
});
15 changes: 15 additions & 0 deletions packages/ai-sdk/src/telemetry.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
14 changes: 13 additions & 1 deletion packages/ai-sdk/src/tools.ts
Original file line number Diff line number Diff line change
@@ -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<INPUT> =
Expand All @@ -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. */
Expand Down Expand Up @@ -66,7 +72,13 @@ function wrapBuiltTool(
* ```
*/
export function cachedTools<T extends ToolSet>(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<string, Tool>;
for (const [name, built] of Object.entries(tools)) {
out[name] = wrapBuiltTool(cache, name, options.userId, options.ttlSeconds, built);
Expand Down
2 changes: 2 additions & 0 deletions packages/ai-sdk/src/version.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading
Loading