@uncoil.dev/sdk 0.0.0-stage → 0.6.0
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/README.md +216 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +88 -0
- package/dist/doctor.d.ts +33 -0
- package/dist/doctor.js +156 -0
- package/dist/index.d.ts +247 -0
- package/dist/index.js +652 -0
- package/dist/integrations/auto.d.ts +16 -0
- package/dist/integrations/auto.js +54 -0
- package/dist/integrations/langgraph.d.ts +24 -0
- package/dist/integrations/langgraph.js +80 -0
- package/dist/integrations/observation.d.ts +31 -0
- package/dist/integrations/observation.js +108 -0
- package/dist/integrations/providers.d.ts +7 -0
- package/dist/integrations/providers.js +224 -0
- package/dist/integrations/tools.d.ts +10 -0
- package/dist/integrations/tools.js +48 -0
- package/dist/integrations/vercel-ai.d.ts +24 -0
- package/dist/integrations/vercel-ai.js +72 -0
- package/dist/login.d.ts +37 -0
- package/dist/login.js +214 -0
- package/package.json +52 -4
package/README.md
CHANGED
|
@@ -1,3 +1,217 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Uncoil TypeScript SDK
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Observability and tracing for AI agents. Gain full visibility into your agent's decision-making process, tool usage, and LLM costs.
|
|
4
|
+
|
|
5
|
+
Uncoil records each agent run as one trace: every model call, tool call, retry, failure and cost. It also ingests OpenTelemetry. Free plan available. Docs: [uncoil.dev/docs](https://www.uncoil.dev/docs). New to agent observability? Read the [complete guide](https://www.uncoil.dev/blog/ai-agent-observability-complete-guide).
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @uncoil.dev/sdk
|
|
11
|
+
# or
|
|
12
|
+
yarn add @uncoil.dev/sdk
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quickstart
|
|
16
|
+
|
|
17
|
+
1. Get your API Key from the [Uncoil Dashboard](https://www.uncoil.dev).
|
|
18
|
+
2. Set it as an environment variable:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
export UNCOIL_API_KEY=your_sk_live_...
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
3. Instrument your agent:
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
import { traceAgent, llmCall, toolCall } from '@uncoil.dev/sdk';
|
|
28
|
+
|
|
29
|
+
const agent = traceAgent(async (query: string) => {
|
|
30
|
+
// LLM Call
|
|
31
|
+
await llmCall({
|
|
32
|
+
input: query,
|
|
33
|
+
output: "Thinking...",
|
|
34
|
+
modelId: "gpt-4o",
|
|
35
|
+
costUsd: 0.002,
|
|
36
|
+
latencyMs: 800
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Tool Use
|
|
40
|
+
await toolCall({
|
|
41
|
+
toolName: "web_search",
|
|
42
|
+
input: { q: query },
|
|
43
|
+
output: "Found results...",
|
|
44
|
+
latencyMs: 1200
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return "Final Answer";
|
|
48
|
+
}, { environment: "development", release: "quickstart" });
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Steps
|
|
52
|
+
|
|
53
|
+
Inside a `traceAgent` function, every step you record is linked to the step it ran in, so the trace graph
|
|
54
|
+
shows which step called which and how loops repeat. Use `span` for a block and `observe` for a function:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { observe, span, traceAgent } from "@uncoil.dev/sdk";
|
|
58
|
+
|
|
59
|
+
const searchDocs = observe(async function searchDocs(query: string) {
|
|
60
|
+
return search(query);
|
|
61
|
+
}, { type: "tool" });
|
|
62
|
+
|
|
63
|
+
export const answer = traceAgent(async (question: string) => {
|
|
64
|
+
for (let turn = 0; turn < 5; turn++) {
|
|
65
|
+
const reply = await span("think", () =>
|
|
66
|
+
span("chat", async (step) => {
|
|
67
|
+
const reply = await callModel(question);
|
|
68
|
+
step.set({ model: "claude-sonnet-5-5", inputTokens: reply.usage.input, outputTokens: reply.usage.output });
|
|
69
|
+
return reply;
|
|
70
|
+
}, { type: "llm", input: question }),
|
|
71
|
+
{ type: "agent" });
|
|
72
|
+
if (reply.done) return reply.text;
|
|
73
|
+
await searchDocs(reply.query);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`type` is one of `agent`, `llm`, `tool`, `retrieval` or `span`. Errors are recorded with their type and
|
|
79
|
+
message. Concurrent runs stay in separate traces (Node's AsyncLocalStorage). Inputs and outputs have obvious
|
|
80
|
+
secrets (bearer tokens, API keys, JWTs, `password=` values) removed and are capped at 10,000 characters;
|
|
81
|
+
avoid sending anything else you would not want stored.
|
|
82
|
+
|
|
83
|
+
## Features
|
|
84
|
+
|
|
85
|
+
- **traceAgent Wrapper:** Automatically captures run lifecycle and errors.
|
|
86
|
+
- **Granular Spans:** Manually record LLMs, tools, and custom decisions.
|
|
87
|
+
- **Environment Aware:** Automatically picks up API keys from `process.env`.
|
|
88
|
+
- **Lightweight:** Minimal dependencies, optimized for Node.js and Edge runtimes.
|
|
89
|
+
- **Session context:** Add `sessionId`, `endUserId`, `environment`, `release`, and `tags` to connect related traces.
|
|
90
|
+
|
|
91
|
+
## Feedback and evaluation scores
|
|
92
|
+
|
|
93
|
+
Pass `projectId` when constructing the client to attach end-user feedback and typed scores to traces:
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
const client = new UncoilClient({ apiKey: process.env.UNCOIL_API_KEY, projectId: "your-project-id" });
|
|
97
|
+
client.feedback(runId, true, { kind: "thumb", comment: "Helpful answer" });
|
|
98
|
+
client.score(runId, "answer_quality", 0.92, { dataType: "numeric" });
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
These helpers use the project API key, deduplicate when given a `dedupeKey`, and never throw into the agent process.
|
|
102
|
+
|
|
103
|
+
## Prompt deployment
|
|
104
|
+
|
|
105
|
+
Resolve a deployed prompt without shipping a new application build:
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
const deployed = await client.getPrompt("support-agent", "production");
|
|
109
|
+
await client.ingest({ spanType: "llm", input: question, output, promptVersionId: deployed.version.id });
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Prompt responses are cached for 60 seconds by default. Pass `{ cacheTtlMs: 0, fallback }` for development or guaranteed-availability behavior.
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT
|
|
117
|
+
|
|
118
|
+
## Provider integrations (0.4.0)
|
|
119
|
+
|
|
120
|
+
Wrap an existing client once. Its normal calls, typed results, streaming chunks and errors keep working.
|
|
121
|
+
Provider packages are optional; install the providers you use. Node.js is required (async context propagation).
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import OpenAI from "openai";
|
|
125
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
126
|
+
import { wrapOpenAI, wrapAnthropic, UncoilLangGraphHandler, traceAgent } from "@uncoil.dev/sdk";
|
|
127
|
+
|
|
128
|
+
const openai = wrapOpenAI(new OpenAI());
|
|
129
|
+
const anthropic = wrapAnthropic(new Anthropic());
|
|
130
|
+
// Existing calls now record model, input/output, latency, tokens, TTFT and provider errors.
|
|
131
|
+
const reply = await openai.responses.create({ model: process.env.OPENAI_MODEL!, input: "Hello" });
|
|
132
|
+
const stream = await anthropic.messages.create({
|
|
133
|
+
model: process.env.ANTHROPIC_MODEL!, max_tokens: 100,
|
|
134
|
+
messages: [{ role: "user", content: "Hello" }], stream: true,
|
|
135
|
+
});
|
|
136
|
+
for await (const event of stream) { /* existing handling */ }
|
|
137
|
+
|
|
138
|
+
// For LangGraph, pass one reusable handler in the graph invocation config.
|
|
139
|
+
await graph.invoke(input, { callbacks: [new UncoilLangGraphHandler()] });
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Use `UNCOIL_API_KEY` and the existing region/host configuration. Pass `{ client }` for a custom
|
|
143
|
+
`UncoilClient`. Calls inside `traceAgent`/`span` inherit the current run and parent; standalone provider
|
|
144
|
+
calls get a completed root, so unrelated requests never share an `unknown` run. LangGraph callbacks
|
|
145
|
+
record graph/node/tool/model parent relationships. Use either framework model callbacks or a wrapped
|
|
146
|
+
provider for each model call to avoid recording that call twice.
|
|
147
|
+
|
|
148
|
+
Supported APIs: OpenAI `chat.completions.create`, `responses.create`, and their `.stream()` helpers;
|
|
149
|
+
Anthropic `messages.create`, `messages.stream`, and beta messages equivalents. Async iteration and
|
|
150
|
+
helper event/final-result APIs are observed. OpenAI `.withResponse()` retains headers/request IDs;
|
|
151
|
+
`.asResponse()` remains unparsed and records request timing only. Stream conversion helpers such as
|
|
152
|
+
`.tee()` and `.toReadableStream()` are passed through and do not observe consumption: iterate the
|
|
153
|
+
wrapped stream for complete usage/TTFT. Explicit `stream_options.include_usage: false` is respected.
|
|
154
|
+
Cancel/abort records partial output once; telemetry failure never replaces the provider's result or error.
|
|
155
|
+
|
|
156
|
+
Payloads use the SDK's existing redaction and size caps. Disable capture with
|
|
157
|
+
`{ captureInput: false, captureOutput: false }`. Provider usage is stored exactly as returned, including
|
|
158
|
+
cache breakdown in metadata. **Prices are not guessed:** cost defaults to zero with
|
|
159
|
+
`metadata.costKnown=false`. Supply `calculateCost({provider, model, inputTokens, outputTokens, usage})`
|
|
160
|
+
using your own current rate card to record USD cost. Missing provider usage remains unknown, not an
|
|
161
|
+
estimated token count. None of these wrappers publishes your key or changes provider billing.
|
|
162
|
+
|
|
163
|
+
Anthropic total input tokens include uncached input plus cache reads and cache writes; the original
|
|
164
|
+
usage breakdown remains in metadata for rate-card calculations. See the [provider’s token breakdown](https://platform.claude.com/docs/en/build-with-claude/prompt-caching).
|
|
165
|
+
|
|
166
|
+
## Framework one-liners
|
|
167
|
+
|
|
168
|
+
Quickest start: call `init()` once at startup and every OpenAI and Anthropic call is traced, with no client wrapping (`UNCOIL_API_KEY` is read from the environment).
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
import { init } from "@uncoil.dev/sdk";
|
|
172
|
+
init();
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`init()` patches the installed `openai` and `@anthropic-ai/sdk` (both the CommonJS and ES-module builds; `await init().ready` if your first call happens instantly). Call the function it returns to switch it off. Use it or the per-framework options below for the same calls, not both. LangChain / LangGraph need `callbacks: [new UncoilLangGraphHandler()]`, and the Vercel AI SDK needs the middleware shown here.
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
import { wrapLanguageModel } from "ai";
|
|
179
|
+
import { uncoilMiddleware, traceTools } from "@uncoil.dev/sdk";
|
|
180
|
+
|
|
181
|
+
const model = wrapLanguageModel({ model: openai("gpt-4o"), middleware: uncoilMiddleware() }); // Vercel AI SDK
|
|
182
|
+
const tools = traceTools({ searchDocs, sendEmail }); // any functions or { execute } tool objects
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
OpenAI (`wrapOpenAI`), Anthropic (`wrapAnthropic`) and LangGraph (`UncoilLangGraphHandler`) work as before. Copy-paste starters live in [`templates/`](./templates).
|
|
186
|
+
|
|
187
|
+
## Sampling, redaction and clearer errors
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
const uncoil = new UncoilClient({
|
|
191
|
+
sampleRate: 0.1, // record about 1 in 10 runs; each run is kept or dropped whole
|
|
192
|
+
redact: { patterns: [/ACME-\d{6}/] }, // emails, phones and card numbers are masked by default; add your own patterns
|
|
193
|
+
onError: (error) => log(error.message), // problems come with a plain-language fix (error.hint)
|
|
194
|
+
});
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Spans that report an error are kept even when their run was not sampled (`keepErrors: false` turns that off). Secrets such as bearer tokens and API keys are always removed, and emails, phone numbers and card numbers are masked before anything leaves your app. To send them as they are, set `redact: { pii: false }`.
|
|
198
|
+
|
|
199
|
+
### Sign in from the terminal
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
npx @uncoil.dev/sdk login
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Opens your browser so you can sign in (or create a free account), pick or create a project, and confirm the code shown in your terminal. The key is then saved to `.env.local` as `UNCOIL_API_KEY`, with `UNCOIL_PROJECT_ID` next to it (and `UNCOIL_REGION` for the US region). In a git repo the file is added to `.gitignore` if it is not already ignored. The key travels encrypted to your machine and is never printed. Options: `--region eu|us`, `--new <name>` (create a project with this name), `--no-browser` (only print the address and code), `--env-file <path>` and `--json`. Picking a project that already sends data replaces its key, and the browser asks you to confirm that first. Plain Node does not read `.env.local` by itself; run `node --env-file=.env.local app.js` or load it with dotenv.
|
|
206
|
+
|
|
207
|
+
`npx @uncoil.dev/sdk doctor` also reads `.env.local`/`.env`, and now tells you which project traces will land in (`Traces will land in: <name>`). It fails if `UNCOIL_PROJECT_ID` points at a different project than the key.
|
|
208
|
+
|
|
209
|
+
## Setup checker
|
|
210
|
+
|
|
211
|
+
Not seeing traces? One command checks your key, region and network, then sends a test trace:
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
npx @uncoil.dev/sdk doctor
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
It reads `UNCOIL_API_KEY` (and `UNCOIL_REGION` / `UNCOIL_HOST` if set), tells you in plain words what is wrong and how to fix it, and exits with code 1 if anything fails. Options: `--key`, `--region eu|us`, `--host`, `--no-send` (skip the test trace) and `--json`. You can also call `runDoctor()` from code. The test trace is named "Uncoil setup check" and tagged `uncoil-doctor`; it counts as one trace on your plan.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const doctor_1 = require("./doctor");
|
|
5
|
+
const login_1 = require("./login");
|
|
6
|
+
const HELP = `Usage: uncoil login [options]
|
|
7
|
+
uncoil doctor [options]
|
|
8
|
+
|
|
9
|
+
login Sign in through your browser and save the API key for this folder.
|
|
10
|
+
doctor Check your API key, region and network, then send one test trace.
|
|
11
|
+
|
|
12
|
+
login options:
|
|
13
|
+
--region <eu|us> Data region (default: UNCOIL_REGION, else eu)
|
|
14
|
+
--new <name> Create a new project with this name
|
|
15
|
+
--no-browser Only print the address and code; do not open a browser
|
|
16
|
+
--env-file <path> Where to save the key (default: .env.local)
|
|
17
|
+
--host <url> Custom host (default: UNCOIL_HOST)
|
|
18
|
+
--json Print machine-readable lines
|
|
19
|
+
|
|
20
|
+
doctor options:
|
|
21
|
+
|
|
22
|
+
--key <key> API key (default: UNCOIL_API_KEY)
|
|
23
|
+
--region <eu|us> Data region (default: UNCOIL_REGION, else from the key)
|
|
24
|
+
--host <url> Custom host (default: UNCOIL_HOST)
|
|
25
|
+
--no-send Skip the test trace
|
|
26
|
+
--json Print the result as JSON
|
|
27
|
+
-h, --help Show this help
|
|
28
|
+
|
|
29
|
+
Exit code is 0 when everything passes, 1 when a check fails.`;
|
|
30
|
+
async function main(argv) {
|
|
31
|
+
const command = argv[0] === "login" || argv[0] === "doctor" ? argv[0] : "doctor";
|
|
32
|
+
const args = argv[0] === "login" || argv[0] === "doctor" ? argv.slice(1) : argv;
|
|
33
|
+
const options = {};
|
|
34
|
+
let json = false;
|
|
35
|
+
let newProject;
|
|
36
|
+
for (let i = 0; i < args.length; i++) {
|
|
37
|
+
const arg = args[i];
|
|
38
|
+
const value = () => {
|
|
39
|
+
const next = args[++i];
|
|
40
|
+
if (!next) {
|
|
41
|
+
console.error(`Missing value for ${arg}.\n\n${HELP}`);
|
|
42
|
+
process.exit(2);
|
|
43
|
+
}
|
|
44
|
+
return next;
|
|
45
|
+
};
|
|
46
|
+
if (arg === "-h" || arg === "--help") {
|
|
47
|
+
console.log(HELP);
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
else if (arg === "--key" && command === "doctor")
|
|
51
|
+
options.apiKey = value();
|
|
52
|
+
else if (arg === "--region") {
|
|
53
|
+
const region = value().toLowerCase();
|
|
54
|
+
if (region !== "eu" && region !== "us") {
|
|
55
|
+
console.error('--region must be "eu" or "us".');
|
|
56
|
+
return 2;
|
|
57
|
+
}
|
|
58
|
+
options.region = region;
|
|
59
|
+
}
|
|
60
|
+
else if (arg === "--host")
|
|
61
|
+
options.host = value();
|
|
62
|
+
else if (arg === "--no-send" && command === "doctor")
|
|
63
|
+
options.send = false;
|
|
64
|
+
else if (arg === "--new" && command === "login")
|
|
65
|
+
newProject = value();
|
|
66
|
+
else if (arg === "--no-browser" && command === "login")
|
|
67
|
+
options.browser = false;
|
|
68
|
+
else if (arg === "--env-file" && command === "login")
|
|
69
|
+
options.envFile = value();
|
|
70
|
+
else if (arg === "--json")
|
|
71
|
+
json = true;
|
|
72
|
+
else {
|
|
73
|
+
console.error(`Unknown option ${arg}.\n\n${HELP}`);
|
|
74
|
+
return 2;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (command === "login") {
|
|
78
|
+
const result = await (0, login_1.runLogin)({ ...options, newProject, json });
|
|
79
|
+
if (!result.ok)
|
|
80
|
+
console.error(json ? JSON.stringify({ event: "error", message: result.message }) : result.message);
|
|
81
|
+
return result.ok ? 0 : 1;
|
|
82
|
+
}
|
|
83
|
+
(0, login_1.loadUncoilEnv)(process.cwd());
|
|
84
|
+
const report = await (0, doctor_1.runDoctor)(options);
|
|
85
|
+
console.log(json ? JSON.stringify(report, null, 2) : (0, doctor_1.formatReport)(report));
|
|
86
|
+
return report.ok ? 0 : 1;
|
|
87
|
+
}
|
|
88
|
+
main(process.argv.slice(2)).then((code) => { process.exitCode = code; });
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type UncoilRegion } from "./index";
|
|
2
|
+
export type DoctorStatus = "ok" | "warn" | "fail" | "skip";
|
|
3
|
+
export interface DoctorCheck {
|
|
4
|
+
id: "runtime" | "key" | "region" | "network" | "project" | "trace";
|
|
5
|
+
title: string;
|
|
6
|
+
status: DoctorStatus;
|
|
7
|
+
message: string;
|
|
8
|
+
/** Plain-language fix, present when the check did not pass. */
|
|
9
|
+
hint?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface DoctorOptions {
|
|
12
|
+
apiKey?: string;
|
|
13
|
+
region?: UncoilRegion;
|
|
14
|
+
host?: string;
|
|
15
|
+
/** Skip the test trace; only check the key shape, region and network. */
|
|
16
|
+
send?: boolean;
|
|
17
|
+
/** Project the key is expected to belong to. Defaults to UNCOIL_PROJECT_ID. */
|
|
18
|
+
projectId?: string;
|
|
19
|
+
/** Seconds before a request is given up on. Defaults to 10. */
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
}
|
|
22
|
+
export interface DoctorReport {
|
|
23
|
+
ok: boolean;
|
|
24
|
+
host: string;
|
|
25
|
+
checks: DoctorCheck[];
|
|
26
|
+
/** Set when the test trace was accepted. Search for it in your dashboard. */
|
|
27
|
+
runId?: string;
|
|
28
|
+
}
|
|
29
|
+
/** Shows enough of a key to recognise it without leaking it. */
|
|
30
|
+
export declare function maskKey(apiKey: string): string;
|
|
31
|
+
/** Runs every setup check in order and never throws. */
|
|
32
|
+
export declare function runDoctor(options?: DoctorOptions): Promise<DoctorReport>;
|
|
33
|
+
export declare function formatReport(report: DoctorReport): string;
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.maskKey = maskKey;
|
|
4
|
+
exports.runDoctor = runDoctor;
|
|
5
|
+
exports.formatReport = formatReport;
|
|
6
|
+
const index_1 = require("./index");
|
|
7
|
+
function keyRegion(apiKey) {
|
|
8
|
+
if (/^(uncoil|tracify)_sk_live_eu_/.test(apiKey))
|
|
9
|
+
return "eu";
|
|
10
|
+
if (/^(uncoil|tracify)_sk_live_us_/.test(apiKey))
|
|
11
|
+
return "us";
|
|
12
|
+
if (/^(uncoil|tracify)_sk_live_/.test(apiKey))
|
|
13
|
+
return "eu";
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
/** Shows enough of a key to recognise it without leaking it. */
|
|
17
|
+
function maskKey(apiKey) {
|
|
18
|
+
if (apiKey.length <= 12)
|
|
19
|
+
return "***";
|
|
20
|
+
return `${apiKey.slice(0, 16)}…${apiKey.slice(-4)}`;
|
|
21
|
+
}
|
|
22
|
+
function id() {
|
|
23
|
+
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
|
24
|
+
}
|
|
25
|
+
function errorText(error) {
|
|
26
|
+
const err = error;
|
|
27
|
+
if (err?.name === "TimeoutError" || err?.name === "AbortError")
|
|
28
|
+
return "the request timed out";
|
|
29
|
+
return err?.cause?.code || err?.cause?.message || err?.message || String(error);
|
|
30
|
+
}
|
|
31
|
+
/** Runs every setup check in order and never throws. */
|
|
32
|
+
async function runDoctor(options = {}) {
|
|
33
|
+
const checks = [];
|
|
34
|
+
const add = (check) => { checks.push(check); return check; };
|
|
35
|
+
const timeoutMs = options.timeoutMs ?? 10000;
|
|
36
|
+
const apiKey = (options.apiKey || (process.env.UNCOIL_API_KEY ?? process.env.TRACIFY_API_KEY) || "").trim();
|
|
37
|
+
const envRegion = (process.env.UNCOIL_REGION ?? process.env.TRACIFY_REGION)?.trim().toLowerCase();
|
|
38
|
+
const wanted = (options.region || (envRegion === "eu" || envRegion === "us" ? envRegion : undefined));
|
|
39
|
+
const found = keyRegion(apiKey);
|
|
40
|
+
const host = (options.host || (process.env.UNCOIL_HOST ?? process.env.TRACIFY_HOST) || index_1.UNCOIL_REGION_HOSTS[wanted ?? found ?? "eu"]).replace(/\/$/, "");
|
|
41
|
+
const report = () => ({ ok: !checks.some((c) => c.status === "fail"), host, checks, runId });
|
|
42
|
+
let runId;
|
|
43
|
+
const nodeMajor = Number(process.versions?.node?.split(".")[0]);
|
|
44
|
+
if (typeof fetch !== "function" || nodeMajor < 18) {
|
|
45
|
+
add({ id: "runtime", title: "Runtime", status: "fail", message: `This runtime has no built-in fetch (Node ${process.versions?.node ?? "unknown"}).`, hint: "Use Node 18 or newer." });
|
|
46
|
+
return report();
|
|
47
|
+
}
|
|
48
|
+
add({ id: "runtime", title: "Runtime", status: "ok", message: `Node ${process.versions.node}` });
|
|
49
|
+
if (!apiKey) {
|
|
50
|
+
add({ id: "key", title: "API key", status: "fail", message: "No API key was found.", hint: "Set the UNCOIL_API_KEY environment variable, or run with --key. Create a key on the API keys page of your dashboard." });
|
|
51
|
+
return report();
|
|
52
|
+
}
|
|
53
|
+
if (!/^(uncoil|tracify)_sk_/.test(apiKey)) {
|
|
54
|
+
add({ id: "key", title: "API key", status: "fail", message: `${maskKey(apiKey)} does not look like an Uncoil key (they start with uncoil_sk_).`, hint: "Copy the key again from the API keys page; make sure no other service's key was pasted by mistake." });
|
|
55
|
+
return report();
|
|
56
|
+
}
|
|
57
|
+
add({ id: "key", title: "API key", status: "ok", message: `Found ${maskKey(apiKey)}` });
|
|
58
|
+
try {
|
|
59
|
+
new URL(host);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
add({ id: "region", title: "Region and host", status: "fail", message: `The host "${host}" is not a valid web address.`, hint: 'Use a full address such as "https://eu.cloud.uncoil.dev", or drop the host option and use --region eu or us.' });
|
|
63
|
+
return report();
|
|
64
|
+
}
|
|
65
|
+
const hostRegion = (Object.entries(index_1.UNCOIL_REGION_HOSTS).find(([, value]) => value === host)?.[0]);
|
|
66
|
+
if (found && hostRegion && found !== hostRegion) {
|
|
67
|
+
add({ id: "region", title: "Region and host", status: "fail", message: `This key belongs to ${found.toUpperCase()}, but you are pointing at ${hostRegion.toUpperCase()}.`, hint: `Set UNCOIL_REGION=${found} (or run with --region ${found}), or create a key in the ${hostRegion.toUpperCase()} region.` });
|
|
68
|
+
return report();
|
|
69
|
+
}
|
|
70
|
+
add({ id: "region", title: "Region and host", status: "ok", message: hostRegion ? `${hostRegion.toUpperCase()} · ${host}` : `Custom host · ${host}` });
|
|
71
|
+
const proxyNote = process.env.HTTPS_PROXY || process.env.https_proxy ? " A proxy is set in your environment, and Node's fetch does not use it automatically; allow this host or route the request through your proxy." : "";
|
|
72
|
+
try {
|
|
73
|
+
const response = await fetch(host, { method: "GET", redirect: "manual", signal: AbortSignal.timeout(timeoutMs) });
|
|
74
|
+
add({ id: "network", title: "Network", status: "ok", message: `Reached ${host} (HTTP ${response.status}).` });
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
add({ id: "network", title: "Network", status: "fail", message: `Could not reach ${host} (${errorText(error)}).`, hint: `Check your internet connection, firewall and the host setting.${proxyNote}` });
|
|
78
|
+
return report();
|
|
79
|
+
}
|
|
80
|
+
// Which project does this key send to? Older servers have no such route, so anything unexpected is skipped quietly.
|
|
81
|
+
const expectedProject = (options.projectId || (process.env.UNCOIL_PROJECT_ID ?? process.env.TRACIFY_PROJECT_ID) || "").trim();
|
|
82
|
+
try {
|
|
83
|
+
const response = await fetch(`${host}/api/sdk/whoami`, { headers: { Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(timeoutMs) });
|
|
84
|
+
const data = response.ok ? await response.json() : null;
|
|
85
|
+
if (data?.projectId) {
|
|
86
|
+
if (expectedProject && expectedProject !== data.projectId) {
|
|
87
|
+
add({ id: "project", title: "Project", status: "fail", message: `UNCOIL_PROJECT_ID points at a different project than this key ("${data.projectName ?? data.projectId}").`, hint: "Run `npx @uncoil.dev/sdk login` in this folder again so the key and project are saved together." });
|
|
88
|
+
return report();
|
|
89
|
+
}
|
|
90
|
+
add({ id: "project", title: "Project", status: "ok", message: `Traces will land in: ${data.projectName ?? data.projectId}` });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch { /* the trace check below reports connection problems */ }
|
|
94
|
+
if (options.send === false) {
|
|
95
|
+
add({ id: "trace", title: "Test trace", status: "skip", message: "Skipped (--no-send)." });
|
|
96
|
+
return report();
|
|
97
|
+
}
|
|
98
|
+
const spanRunId = `doctor_${id()}`;
|
|
99
|
+
const started = Date.now();
|
|
100
|
+
const span = {
|
|
101
|
+
spanId: `doctor_span_${id()}`,
|
|
102
|
+
runId: spanRunId,
|
|
103
|
+
traceName: "Uncoil setup check",
|
|
104
|
+
spanType: "llm_call",
|
|
105
|
+
modelId: "uncoil-doctor",
|
|
106
|
+
createdAt: new Date(started).toISOString(),
|
|
107
|
+
latencyMs: 1,
|
|
108
|
+
input: "Setup check from uncoil doctor",
|
|
109
|
+
output: "If you can see this trace in your dashboard, the SDK is set up correctly.",
|
|
110
|
+
tags: ["uncoil-doctor"],
|
|
111
|
+
metadata: { source: "uncoil doctor", sdk: "ts" },
|
|
112
|
+
};
|
|
113
|
+
try {
|
|
114
|
+
const response = await fetch(`${host}/api/ingest`, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
117
|
+
body: JSON.stringify(span),
|
|
118
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
119
|
+
});
|
|
120
|
+
const status = response.status;
|
|
121
|
+
if (response.ok) {
|
|
122
|
+
runId = spanRunId;
|
|
123
|
+
add({ id: "trace", title: "Test trace", status: "ok", message: `Accepted. Look for "Uncoil setup check" (run ${spanRunId}) in your dashboard.` });
|
|
124
|
+
}
|
|
125
|
+
else if (status === 401) {
|
|
126
|
+
add({ id: "trace", title: "Test trace", status: "fail", message: "Uncoil rejected the API key (401).", hint: "The key is wrong, was revoked, or belongs to another region. Create a fresh one on the API keys page." });
|
|
127
|
+
}
|
|
128
|
+
else if (status === 403) {
|
|
129
|
+
add({ id: "trace", title: "Test trace", status: "fail", message: "This key is not allowed to send data (403).", hint: "Check that the key belongs to the project you expect and has not been disabled." });
|
|
130
|
+
}
|
|
131
|
+
else if (status === 402 || status === 429) {
|
|
132
|
+
add({ id: "trace", title: "Test trace", status: "fail", message: `Uncoil is not taking data right now (${status}).`, hint: "You may be over your plan's limit or sending too fast. Check usage on the billing page, then try again." });
|
|
133
|
+
}
|
|
134
|
+
else if (status >= 500) {
|
|
135
|
+
add({ id: "trace", title: "Test trace", status: "fail", message: `Uncoil had a problem on its side (${status}).`, hint: "Your app is not affected. Check the status page and try again in a minute." });
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
add({ id: "trace", title: "Test trace", status: "fail", message: `Uncoil did not accept the test trace (${status}).`, hint: "Update uncoil to the latest version; if it still happens, contact support." });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
add({ id: "trace", title: "Test trace", status: "fail", message: `The test trace could not be sent (${errorText(error)}).`, hint: `Check your connection and try again.${proxyNote}` });
|
|
143
|
+
}
|
|
144
|
+
return report();
|
|
145
|
+
}
|
|
146
|
+
const MARKS = { ok: "✓", warn: "!", fail: "✗", skip: "-" };
|
|
147
|
+
function formatReport(report) {
|
|
148
|
+
const lines = ["Uncoil setup check", ""];
|
|
149
|
+
for (const check of report.checks) {
|
|
150
|
+
lines.push(`${MARKS[check.status]} ${check.title}: ${check.message}`);
|
|
151
|
+
if (check.hint && check.status !== "ok")
|
|
152
|
+
lines.push(` Fix: ${check.hint}`);
|
|
153
|
+
}
|
|
154
|
+
lines.push("", report.ok ? "All good. Your setup is ready to send traces." : "Something needs fixing; see the Fix lines above.");
|
|
155
|
+
return lines.join("\n");
|
|
156
|
+
}
|