@respan/cli 0.11.0 → 0.12.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/dist/commands/demo/clear.d.ts +23 -0
- package/dist/commands/demo/clear.js +114 -0
- package/dist/commands/demo/index.d.ts +15 -0
- package/dist/commands/demo/index.js +81 -0
- package/dist/lib/demo/dataset-fixtures.d.ts +32 -0
- package/dist/lib/demo/dataset-fixtures.js +194 -0
- package/dist/lib/demo/evaluator-fixtures.d.ts +41 -0
- package/dist/lib/demo/evaluator-fixtures.js +251 -0
- package/dist/lib/demo/prompt-fixtures.d.ts +54 -0
- package/dist/lib/demo/prompt-fixtures.js +395 -0
- package/dist/lib/demo/seed-datasets.d.ts +15 -0
- package/dist/lib/demo/seed-datasets.js +107 -0
- package/dist/lib/demo/seed-evaluators.d.ts +22 -0
- package/dist/lib/demo/seed-evaluators.js +180 -0
- package/dist/lib/demo/seed-prompts.d.ts +10 -0
- package/dist/lib/demo/seed-prompts.js +111 -0
- package/dist/lib/demo/seed-traces.d.ts +27 -0
- package/dist/lib/demo/seed-traces.js +158 -0
- package/dist/lib/demo/seeders.d.ts +8 -0
- package/dist/lib/demo/seeders.js +11 -0
- package/dist/lib/demo/trace-fixtures.d.ts +31 -0
- package/dist/lib/demo/trace-fixtures.js +79 -0
- package/dist/lib/demo/types.d.ts +57 -0
- package/dist/lib/demo/types.js +9 -0
- package/oclif.manifest.json +784 -644
- package/package.json +1 -1
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evaluators seeder for `respan demo`.
|
|
3
|
+
*
|
|
4
|
+
* Recreates the demo evaluators (see evaluator-fixtures.ts) in the authenticated
|
|
5
|
+
* account and tears them down for `respan demo clear`. Each evaluator is two kinds
|
|
6
|
+
* of resource: grader record(s) and a Blockly workflow that references them.
|
|
7
|
+
*
|
|
8
|
+
* Recreate recipe (the engine — LLM vs human — lives in the workflow node, not the
|
|
9
|
+
* grader record, so the captured graph is replayed verbatim):
|
|
10
|
+
* 1. create each grader (or reuse one with the same name)
|
|
11
|
+
* 2. map each grader's captured `originalId` to its new id
|
|
12
|
+
* 3. deep-clone the task graph and rewrite every eval task's `config.evaluator_id`
|
|
13
|
+
* via that map (task ids are kept verbatim so the compute task's
|
|
14
|
+
* `state.<task_id>` input references stay valid)
|
|
15
|
+
* 4. createWorkflow -> commit -> deploy v1
|
|
16
|
+
*
|
|
17
|
+
* Dedup is by workflow name (the user-facing evaluator name). The SDK has no commit
|
|
18
|
+
* method, so commit is a raw POST to `/api/workflows/{id}/commits/` (as in
|
|
19
|
+
* lib/eval/runner.ts). All evaluator-specific logic lives here.
|
|
20
|
+
*/
|
|
21
|
+
import { DEMO_EVALUATORS } from './evaluator-fixtures.js';
|
|
22
|
+
const WORKFLOW_NAMES = new Set(DEMO_EVALUATORS.map((e) => e.workflowName));
|
|
23
|
+
const GRADER_NAMES = new Set(DEMO_EVALUATORS.flatMap((e) => e.graders.map((g) => g.name)));
|
|
24
|
+
/** Demo evaluator workflows present in the account, matched by exact name. */
|
|
25
|
+
async function findExistingWorkflows(ctx) {
|
|
26
|
+
const response = await ctx.client.workflows.listWorkflows({
|
|
27
|
+
Authorization: ctx.authHeader,
|
|
28
|
+
page_size: 100,
|
|
29
|
+
});
|
|
30
|
+
const found = [];
|
|
31
|
+
for (const row of response.results ?? []) {
|
|
32
|
+
const name = typeof row.name === 'string' ? row.name : '';
|
|
33
|
+
const id = String(row.workflow_id ?? row.id ?? '');
|
|
34
|
+
if (id && WORKFLOW_NAMES.has(name))
|
|
35
|
+
found.push({ name, id });
|
|
36
|
+
}
|
|
37
|
+
return found;
|
|
38
|
+
}
|
|
39
|
+
/** Demo grader records present in the account, matched by exact name. */
|
|
40
|
+
async function findExistingGraders(ctx) {
|
|
41
|
+
const response = await ctx.client.evaluators.listEvaluators({ Authorization: ctx.authHeader });
|
|
42
|
+
const found = [];
|
|
43
|
+
for (const row of response.results ?? []) {
|
|
44
|
+
const name = typeof row.name === 'string' ? row.name : '';
|
|
45
|
+
const id = String(row.id ?? '');
|
|
46
|
+
if (id && GRADER_NAMES.has(name))
|
|
47
|
+
found.push({ name, id });
|
|
48
|
+
}
|
|
49
|
+
return found;
|
|
50
|
+
}
|
|
51
|
+
/** Create each grader the evaluator needs (reusing one of the same name if it
|
|
52
|
+
* already exists) and return a map from captured `originalId` to the live id. */
|
|
53
|
+
async function ensureGraders(ctx, fixture, existingByName) {
|
|
54
|
+
const idMap = new Map();
|
|
55
|
+
for (const grader of fixture.graders) {
|
|
56
|
+
let id = existingByName.get(grader.name);
|
|
57
|
+
if (!id) {
|
|
58
|
+
const created = (await ctx.client.evaluators.createEvaluator({
|
|
59
|
+
Authorization: ctx.authHeader,
|
|
60
|
+
name: grader.name,
|
|
61
|
+
type: grader.type,
|
|
62
|
+
score_value_type: grader.score_value_type,
|
|
63
|
+
score_config: grader.score_config,
|
|
64
|
+
passing_conditions: grader.passing_conditions,
|
|
65
|
+
llm_config: grader.llm_config,
|
|
66
|
+
}));
|
|
67
|
+
id = String(created.id ?? created.evaluator_id ?? '');
|
|
68
|
+
if (!id)
|
|
69
|
+
throw new Error(`createEvaluator did not return an id for grader "${grader.name}".`);
|
|
70
|
+
existingByName.set(grader.name, id);
|
|
71
|
+
}
|
|
72
|
+
idMap.set(grader.originalId, id);
|
|
73
|
+
}
|
|
74
|
+
return idMap;
|
|
75
|
+
}
|
|
76
|
+
/** Deep-clone the captured task graph and point every eval task at the live grader
|
|
77
|
+
* id. Tasks without an `evaluator_id` (transform/compute) pass through unchanged. */
|
|
78
|
+
function remapTasks(tasks, idMap) {
|
|
79
|
+
const clone = JSON.parse(JSON.stringify(tasks));
|
|
80
|
+
for (const task of clone) {
|
|
81
|
+
const config = task.config;
|
|
82
|
+
const original = config?.evaluator_id;
|
|
83
|
+
if (typeof original === 'string') {
|
|
84
|
+
const live = idMap.get(original);
|
|
85
|
+
if (!live)
|
|
86
|
+
throw new Error(`task references unknown grader id ${original}`);
|
|
87
|
+
config.evaluator_id = live;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return clone;
|
|
91
|
+
}
|
|
92
|
+
function normalizeBaseUrl(baseUrl) {
|
|
93
|
+
return baseUrl.replace(/\/+$/, '').replace(/\/api$/, '');
|
|
94
|
+
}
|
|
95
|
+
/** Commit the workflow's draft (no SDK method — raw POST, as in runner.ts). */
|
|
96
|
+
async function commitWorkflow(ctx, workflowId) {
|
|
97
|
+
const url = `${normalizeBaseUrl(ctx.baseUrl)}/api/workflows/${encodeURIComponent(workflowId)}/commits/`;
|
|
98
|
+
const response = await fetch(url, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: { 'Content-Type': 'application/json', Authorization: ctx.authHeader },
|
|
101
|
+
body: JSON.stringify({}),
|
|
102
|
+
});
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
const text = await response.text().catch(() => '');
|
|
105
|
+
throw new Error(`commitWorkflow ${response.status}: ${text || response.statusText}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Create the grader(s) and the deployed workflow for one evaluator. */
|
|
109
|
+
async function createFromFixture(ctx, fixture, existingGradersByName) {
|
|
110
|
+
const idMap = await ensureGraders(ctx, fixture, existingGradersByName);
|
|
111
|
+
const tasks = remapTasks(fixture.tasks, idMap);
|
|
112
|
+
const created = (await ctx.client.workflows.createWorkflow({
|
|
113
|
+
Authorization: ctx.authHeader,
|
|
114
|
+
type: 'evaluators',
|
|
115
|
+
trigger_event_type: 'eval_only',
|
|
116
|
+
name: fixture.workflowName,
|
|
117
|
+
tasks: tasks,
|
|
118
|
+
}));
|
|
119
|
+
const workflowId = String(created.workflow_id ?? created.id ?? '');
|
|
120
|
+
if (!workflowId)
|
|
121
|
+
throw new Error(`createWorkflow did not return an id for "${fixture.workflowName}".`);
|
|
122
|
+
await commitWorkflow(ctx, workflowId);
|
|
123
|
+
await ctx.client.workflows.deployWorkflow({
|
|
124
|
+
Authorization: ctx.authHeader,
|
|
125
|
+
workflow_id: workflowId,
|
|
126
|
+
version: 1,
|
|
127
|
+
});
|
|
128
|
+
return workflowId;
|
|
129
|
+
}
|
|
130
|
+
export const evaluatorsSeeder = {
|
|
131
|
+
service: 'evaluators',
|
|
132
|
+
label: 'Evaluators',
|
|
133
|
+
/** Both workflows and graders, so the clear preview/teardown covers every
|
|
134
|
+
* resource the seeder owns. */
|
|
135
|
+
async findExisting(ctx) {
|
|
136
|
+
const [workflows, graders] = await Promise.all([
|
|
137
|
+
findExistingWorkflows(ctx),
|
|
138
|
+
findExistingGraders(ctx),
|
|
139
|
+
]);
|
|
140
|
+
return [...workflows, ...graders];
|
|
141
|
+
},
|
|
142
|
+
async run(ctx) {
|
|
143
|
+
const existingWorkflows = new Set((await findExistingWorkflows(ctx)).map((w) => w.name));
|
|
144
|
+
const existingGradersByName = new Map((await findExistingGraders(ctx)).map((g) => [g.name, g.id]));
|
|
145
|
+
const items = [];
|
|
146
|
+
for (const fixture of DEMO_EVALUATORS) {
|
|
147
|
+
if (existingWorkflows.has(fixture.workflowName)) {
|
|
148
|
+
items.push({ name: fixture.workflowName, status: 'skipped', reason: 'already exists' });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const id = await createFromFixture(ctx, fixture, existingGradersByName);
|
|
152
|
+
items.push({ name: fixture.workflowName, status: 'created', id });
|
|
153
|
+
}
|
|
154
|
+
return { service: this.service, label: this.label, items };
|
|
155
|
+
},
|
|
156
|
+
async clear(ctx) {
|
|
157
|
+
const items = [];
|
|
158
|
+
// Delete workflows first, then the grader records they referenced.
|
|
159
|
+
for (const match of await findExistingWorkflows(ctx)) {
|
|
160
|
+
await ctx.client.workflows.deleteWorkflow({
|
|
161
|
+
Authorization: ctx.authHeader,
|
|
162
|
+
workflow_id: match.id,
|
|
163
|
+
});
|
|
164
|
+
items.push({ name: match.name, status: 'deleted', id: match.id });
|
|
165
|
+
}
|
|
166
|
+
for (const match of await findExistingGraders(ctx)) {
|
|
167
|
+
await ctx.client.evaluators.deleteEvaluator({
|
|
168
|
+
Authorization: ctx.authHeader,
|
|
169
|
+
evaluator_id: match.id,
|
|
170
|
+
});
|
|
171
|
+
items.push({ name: match.name, status: 'deleted', id: match.id });
|
|
172
|
+
}
|
|
173
|
+
if (items.length === 0) {
|
|
174
|
+
for (const fixture of DEMO_EVALUATORS) {
|
|
175
|
+
items.push({ name: fixture.workflowName, status: 'skipped', reason: 'not found' });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { service: this.service, label: this.label, items };
|
|
179
|
+
},
|
|
180
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompts seeder for `respan demo`.
|
|
3
|
+
*
|
|
4
|
+
* Recreates the three demo prompts (name + single deployed live version) in the
|
|
5
|
+
* authenticated account, and tears them down again for `respan demo clear`.
|
|
6
|
+
* Deduplicates by prompt name so re-runs never create duplicates. All
|
|
7
|
+
* prompt-specific logic lives here; the orchestrator stays service-agnostic.
|
|
8
|
+
*/
|
|
9
|
+
import { Seeder } from './types.js';
|
|
10
|
+
export declare const promptsSeeder: Seeder;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompts seeder for `respan demo`.
|
|
3
|
+
*
|
|
4
|
+
* Recreates the three demo prompts (name + single deployed live version) in the
|
|
5
|
+
* authenticated account, and tears them down again for `respan demo clear`.
|
|
6
|
+
* Deduplicates by prompt name so re-runs never create duplicates. All
|
|
7
|
+
* prompt-specific logic lives here; the orchestrator stays service-agnostic.
|
|
8
|
+
*/
|
|
9
|
+
import { DEMO_PROMPTS } from './prompt-fixtures.js';
|
|
10
|
+
/** Prompts in the account whose name exactly matches a demo fixture name.
|
|
11
|
+
*
|
|
12
|
+
* Queried with the `name` `iexact` filter, one query per fixture name. NOTE: the
|
|
13
|
+
* `icontains`/`iexact` filters do NOT OR-match across a multi-value array — a
|
|
14
|
+
* single `value: [a, b, c]` matches nothing — so each name must be queried on
|
|
15
|
+
* its own. Server matching is case-insensitive, so we re-confirm with an exact,
|
|
16
|
+
* case-sensitive match client-side. Returns every match (so duplicates are all
|
|
17
|
+
* found, for clear). */
|
|
18
|
+
async function findExistingPrompts(ctx) {
|
|
19
|
+
const found = [];
|
|
20
|
+
for (const fixture of DEMO_PROMPTS) {
|
|
21
|
+
const response = await ctx.client.prompts.listPrompts({
|
|
22
|
+
Authorization: ctx.authHeader,
|
|
23
|
+
page_size: 100,
|
|
24
|
+
filters: { name: { operator: 'iexact', value: [fixture.name] } },
|
|
25
|
+
});
|
|
26
|
+
for (const row of response.results ?? []) {
|
|
27
|
+
if (row.id && row.name === fixture.name) {
|
|
28
|
+
found.push({ name: row.name, id: row.id });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return found;
|
|
33
|
+
}
|
|
34
|
+
/** Create one prompt, then commit and deploy its version so it is the live
|
|
35
|
+
* version immediately.
|
|
36
|
+
*
|
|
37
|
+
* The management API treats a freshly created version as an uncommitted draft:
|
|
38
|
+
* `deploy: true` on `createPromptVersion` is a no-op for a draft, and deploying
|
|
39
|
+
* one directly fails ("Cannot deploy a draft version. Commit it first."). The
|
|
40
|
+
* working sequence is create → commit → deploy. (Committing opens a fresh draft
|
|
41
|
+
* on top of the committed version — the same editing model the platform uses
|
|
42
|
+
* for hand-edited prompts.) */
|
|
43
|
+
async function createFromFixture(ctx, fixture) {
|
|
44
|
+
const prompt = await ctx.client.prompts.createPrompt({
|
|
45
|
+
Authorization: ctx.authHeader,
|
|
46
|
+
name: fixture.name,
|
|
47
|
+
...(fixture.description ? { description: fixture.description } : {}),
|
|
48
|
+
});
|
|
49
|
+
const promptId = prompt.id;
|
|
50
|
+
if (!promptId) {
|
|
51
|
+
throw new Error(`createPrompt did not return an id for "${fixture.name}".`);
|
|
52
|
+
}
|
|
53
|
+
const version = await ctx.client.prompts.createPromptVersion({
|
|
54
|
+
Authorization: ctx.authHeader,
|
|
55
|
+
prompt_id: promptId,
|
|
56
|
+
...fixture.version,
|
|
57
|
+
});
|
|
58
|
+
const versionNumber = version.version;
|
|
59
|
+
if (versionNumber == null) {
|
|
60
|
+
throw new Error(`createPromptVersion did not return a version number for "${fixture.name}".`);
|
|
61
|
+
}
|
|
62
|
+
await ctx.client.prompts.commitPromptVersion({
|
|
63
|
+
Authorization: ctx.authHeader,
|
|
64
|
+
prompt_id: promptId,
|
|
65
|
+
});
|
|
66
|
+
await ctx.client.prompts.deployPromptVersion({
|
|
67
|
+
Authorization: ctx.authHeader,
|
|
68
|
+
prompt_id: promptId,
|
|
69
|
+
version: versionNumber,
|
|
70
|
+
});
|
|
71
|
+
return promptId;
|
|
72
|
+
}
|
|
73
|
+
export const promptsSeeder = {
|
|
74
|
+
service: 'prompts',
|
|
75
|
+
label: 'Prompts',
|
|
76
|
+
findExisting(ctx) {
|
|
77
|
+
return findExistingPrompts(ctx);
|
|
78
|
+
},
|
|
79
|
+
async run(ctx) {
|
|
80
|
+
const existingNames = new Set((await findExistingPrompts(ctx)).map((p) => p.name));
|
|
81
|
+
const items = [];
|
|
82
|
+
for (const fixture of DEMO_PROMPTS) {
|
|
83
|
+
if (existingNames.has(fixture.name)) {
|
|
84
|
+
items.push({ name: fixture.name, status: 'skipped', reason: 'already exists' });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const id = await createFromFixture(ctx, fixture);
|
|
88
|
+
items.push({ name: fixture.name, status: 'created', id });
|
|
89
|
+
}
|
|
90
|
+
return { service: this.service, label: this.label, items };
|
|
91
|
+
},
|
|
92
|
+
async clear(ctx) {
|
|
93
|
+
const existing = await findExistingPrompts(ctx);
|
|
94
|
+
const items = [];
|
|
95
|
+
for (const fixture of DEMO_PROMPTS) {
|
|
96
|
+
const matches = existing.filter((p) => p.name === fixture.name);
|
|
97
|
+
if (matches.length === 0) {
|
|
98
|
+
items.push({ name: fixture.name, status: 'skipped', reason: 'not found' });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
for (const match of matches) {
|
|
102
|
+
await ctx.client.prompts.deletePrompt({
|
|
103
|
+
Authorization: ctx.authHeader,
|
|
104
|
+
prompt_id: match.id,
|
|
105
|
+
});
|
|
106
|
+
items.push({ name: match.name, status: 'deleted', id: match.id });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { service: this.service, label: this.label, items };
|
|
110
|
+
},
|
|
111
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Traces seeder for `respan demo`.
|
|
3
|
+
*
|
|
4
|
+
* Ingests the supplied multi-agent service-desk traces (see trace-fixtures.ts)
|
|
5
|
+
* into the authenticated account. All trace-specific logic lives here; the
|
|
6
|
+
* orchestrator stays service-agnostic.
|
|
7
|
+
*
|
|
8
|
+
* Traces are treated as append-only run history: every `respan demo` run ingests a
|
|
9
|
+
* fresh batch stamped at the current time, with freshly-generated trace/span ids.
|
|
10
|
+
* Trace id + timestamps differ on every run, so runs never collide — there is no
|
|
11
|
+
* dedup-skip and no teardown (`respan demo clear` leaves traces in place).
|
|
12
|
+
*
|
|
13
|
+
* Two workflow identifiers, on purpose:
|
|
14
|
+
* - `traceloop.workflow.name` is set to the shared constant `demo`
|
|
15
|
+
* (`span_workflow_name`, a filterable field) on every span, so one marker
|
|
16
|
+
* drives the dashboard filter and link across all demo traces.
|
|
17
|
+
* - the root span's NAME is set to the fixture's unique `workflow`
|
|
18
|
+
* (e.g. `demo.meridian-portal_outage-12`) — shown in the dashboard Workflow
|
|
19
|
+
* column, and unique per scenario. The platform dedups identical span CONTENT
|
|
20
|
+
* within a run (independent of trace id / timestamp), and the dataset repeats
|
|
21
|
+
* some scenarios with a byte-identical root span; a unique root name keeps each
|
|
22
|
+
* one distinct so all 12 ingest without a rejected span.
|
|
23
|
+
*
|
|
24
|
+
* Ingestion is a raw OTLP POST to `/v2/traces` (the SDK does not expose it).
|
|
25
|
+
*/
|
|
26
|
+
import { Seeder } from './types.js';
|
|
27
|
+
export declare const tracesSeeder: Seeder;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Traces seeder for `respan demo`.
|
|
3
|
+
*
|
|
4
|
+
* Ingests the supplied multi-agent service-desk traces (see trace-fixtures.ts)
|
|
5
|
+
* into the authenticated account. All trace-specific logic lives here; the
|
|
6
|
+
* orchestrator stays service-agnostic.
|
|
7
|
+
*
|
|
8
|
+
* Traces are treated as append-only run history: every `respan demo` run ingests a
|
|
9
|
+
* fresh batch stamped at the current time, with freshly-generated trace/span ids.
|
|
10
|
+
* Trace id + timestamps differ on every run, so runs never collide — there is no
|
|
11
|
+
* dedup-skip and no teardown (`respan demo clear` leaves traces in place).
|
|
12
|
+
*
|
|
13
|
+
* Two workflow identifiers, on purpose:
|
|
14
|
+
* - `traceloop.workflow.name` is set to the shared constant `demo`
|
|
15
|
+
* (`span_workflow_name`, a filterable field) on every span, so one marker
|
|
16
|
+
* drives the dashboard filter and link across all demo traces.
|
|
17
|
+
* - the root span's NAME is set to the fixture's unique `workflow`
|
|
18
|
+
* (e.g. `demo.meridian-portal_outage-12`) — shown in the dashboard Workflow
|
|
19
|
+
* column, and unique per scenario. The platform dedups identical span CONTENT
|
|
20
|
+
* within a run (independent of trace id / timestamp), and the dataset repeats
|
|
21
|
+
* some scenarios with a byte-identical root span; a unique root name keeps each
|
|
22
|
+
* one distinct so all 12 ingest without a rejected span.
|
|
23
|
+
*
|
|
24
|
+
* Ingestion is a raw OTLP POST to `/v2/traces` (the SDK does not expose it).
|
|
25
|
+
*/
|
|
26
|
+
import { DEMO_TRACES } from './trace-fixtures.js';
|
|
27
|
+
import { randomBytes } from 'node:crypto';
|
|
28
|
+
/** Shared, filterable workflow marker stamped on every demo span. */
|
|
29
|
+
const DEMO_WORKFLOW = 'demo';
|
|
30
|
+
/** OTLP attribute that becomes the trace's `span_workflow_name`. */
|
|
31
|
+
const WORKFLOW_ATTR = 'traceloop.workflow.name';
|
|
32
|
+
/** Spread the whole batch across the last few minutes so the traces read as fresh,
|
|
33
|
+
* distinct activity and ALL land inside a "last 5 minutes" view. We target a
|
|
34
|
+
* 4-minute window (under 5, with margin) and divide it evenly across the traces,
|
|
35
|
+
* so the newest ends ~now and the oldest ends ~4 minutes ago. */
|
|
36
|
+
const SEED_WINDOW_MS = 4 * 60 * 1000;
|
|
37
|
+
const STAGGER_MS = DEMO_TRACES.length > 1 ? Math.floor(SEED_WINDOW_MS / (DEMO_TRACES.length - 1)) : 0;
|
|
38
|
+
/** Set (or replace) a string OTLP attribute on an attributes array. */
|
|
39
|
+
function setStringAttr(attrs, key, value) {
|
|
40
|
+
const existing = attrs.find((a) => a.key === key);
|
|
41
|
+
if (existing)
|
|
42
|
+
existing.value = { stringValue: value };
|
|
43
|
+
else
|
|
44
|
+
attrs.push({ key, value: { stringValue: value } });
|
|
45
|
+
}
|
|
46
|
+
/** Resolve the OTLP ingest endpoint from the API base URL. Mirrors the hooks'
|
|
47
|
+
* resolver: append `/v2/traces`, inserting `/api` when the base lacks it. */
|
|
48
|
+
function resolveTracesEndpoint(baseUrl) {
|
|
49
|
+
const normalized = (baseUrl || 'https://api.respan.ai').replace(/\/+$/, '');
|
|
50
|
+
return normalized.endsWith('/api')
|
|
51
|
+
? `${normalized}/v2/traces`
|
|
52
|
+
: `${normalized}/api/v2/traces`;
|
|
53
|
+
}
|
|
54
|
+
/** Deep-clone a fixture body, stamp the shared `demo` workflow attribute on every
|
|
55
|
+
* span, set the root span's name to `rootName` (unique per trace — shown in the
|
|
56
|
+
* Workflow column and keeps the root span from colliding with a repeated scenario),
|
|
57
|
+
* assign a fresh trace id, remap every span id, and shift all timestamps so the
|
|
58
|
+
* trace ends at `targetEndMs`. Bodies are pure JSON, so JSON round-trip is a safe,
|
|
59
|
+
* fully-typed clone. */
|
|
60
|
+
function restamp(body, targetEndMs, rootName) {
|
|
61
|
+
const clone = JSON.parse(JSON.stringify(body));
|
|
62
|
+
const traceId = randomBytes(16).toString('hex'); // 32 hex chars
|
|
63
|
+
const spans = [];
|
|
64
|
+
for (const rs of clone.resourceSpans ?? []) {
|
|
65
|
+
for (const ss of rs.scopeSpans ?? []) {
|
|
66
|
+
for (const s of ss.spans ?? []) {
|
|
67
|
+
// Shared filterable marker on every span...
|
|
68
|
+
s.attributes = s.attributes ?? [];
|
|
69
|
+
setStringAttr(s.attributes, WORKFLOW_ATTR, DEMO_WORKFLOW);
|
|
70
|
+
// ...unique display name on the root span.
|
|
71
|
+
if (!s.parentSpanId)
|
|
72
|
+
s.name = rootName;
|
|
73
|
+
spans.push(s);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const idMap = new Map();
|
|
78
|
+
for (const s of spans) {
|
|
79
|
+
if (!idMap.has(s.spanId))
|
|
80
|
+
idMap.set(s.spanId, randomBytes(8).toString('hex')); // 16 hex chars
|
|
81
|
+
}
|
|
82
|
+
let maxEnd = 0n;
|
|
83
|
+
for (const s of spans) {
|
|
84
|
+
const e = BigInt(s.endTimeUnixNano);
|
|
85
|
+
if (e > maxEnd)
|
|
86
|
+
maxEnd = e;
|
|
87
|
+
}
|
|
88
|
+
const targetEndNanos = BigInt(Math.round(targetEndMs)) * 1000000n;
|
|
89
|
+
const delta = targetEndNanos - maxEnd;
|
|
90
|
+
for (const s of spans) {
|
|
91
|
+
s.traceId = traceId;
|
|
92
|
+
s.spanId = idMap.get(s.spanId);
|
|
93
|
+
if (s.parentSpanId)
|
|
94
|
+
s.parentSpanId = idMap.get(s.parentSpanId) ?? s.parentSpanId;
|
|
95
|
+
s.startTimeUnixNano = String(BigInt(s.startTimeUnixNano) + delta);
|
|
96
|
+
s.endTimeUnixNano = String(BigInt(s.endTimeUnixNano) + delta);
|
|
97
|
+
}
|
|
98
|
+
return { body: clone, traceId };
|
|
99
|
+
}
|
|
100
|
+
/** POST one OTLP body to the ingest endpoint. Throws on a 4xx/5xx response, and
|
|
101
|
+
* also on an OTLP `partialSuccess` that rejected spans — so a dropped span surfaces
|
|
102
|
+
* as a loud error instead of a silently span-short trace. */
|
|
103
|
+
async function ingest(endpoint, authHeader, body) {
|
|
104
|
+
const res = await fetch(endpoint, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: {
|
|
107
|
+
Authorization: authHeader,
|
|
108
|
+
'Content-Type': 'application/json',
|
|
109
|
+
'X-Respan-Dogfood': '1', // anti-recursion: don't trace the ingest itself
|
|
110
|
+
},
|
|
111
|
+
body: JSON.stringify(body),
|
|
112
|
+
});
|
|
113
|
+
const text = await res.text().catch(() => '');
|
|
114
|
+
if (res.status >= 400) {
|
|
115
|
+
throw new Error(`Trace ingest failed (HTTP ${res.status}): ${text.slice(0, 200)}`);
|
|
116
|
+
}
|
|
117
|
+
// OTLP returns 200 with a partialSuccess block when some spans are rejected.
|
|
118
|
+
let rejected = 0;
|
|
119
|
+
let message = '';
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(text);
|
|
122
|
+
rejected = Number(parsed.partialSuccess?.rejectedSpans ?? 0);
|
|
123
|
+
message = parsed.partialSuccess?.errorMessage ?? '';
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// Non-JSON success body — nothing to inspect.
|
|
127
|
+
}
|
|
128
|
+
if (rejected > 0) {
|
|
129
|
+
throw new Error(`Trace ingest rejected ${rejected} span(s)${message ? `: ${message}` : ''}.`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export const tracesSeeder = {
|
|
133
|
+
service: 'traces',
|
|
134
|
+
label: 'Traces',
|
|
135
|
+
// Traces are append-only run history — nothing to find for dedup or teardown.
|
|
136
|
+
findExisting() {
|
|
137
|
+
return Promise.resolve([]);
|
|
138
|
+
},
|
|
139
|
+
async run(ctx) {
|
|
140
|
+
const endpoint = resolveTracesEndpoint(ctx.baseUrl);
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
const items = [];
|
|
143
|
+
for (let i = 0; i < DEMO_TRACES.length; i++) {
|
|
144
|
+
const fixture = DEMO_TRACES[i];
|
|
145
|
+
// Fresh trace id + timestamps every run (so runs never collide), staggered
|
|
146
|
+
// backwards from "now", each with a unique root-span name.
|
|
147
|
+
const { body, traceId } = restamp(fixture.body, now - i * STAGGER_MS, fixture.workflow);
|
|
148
|
+
await ingest(endpoint, ctx.authHeader, body);
|
|
149
|
+
items.push({ name: fixture.name, status: 'created', id: traceId });
|
|
150
|
+
}
|
|
151
|
+
return { service: this.service, label: this.label, items };
|
|
152
|
+
},
|
|
153
|
+
// Traces are intentionally left in place — each run is timestamped history, and
|
|
154
|
+
// `respan demo clear` only removes deletable resources (prompts).
|
|
155
|
+
clear() {
|
|
156
|
+
return Promise.resolve({ service: this.service, label: this.label, items: [] });
|
|
157
|
+
},
|
|
158
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The registry of demo seeders, shared by `respan demo` and `respan demo clear`.
|
|
3
|
+
*
|
|
4
|
+
* To add a service later (traces, datasets, evaluators), implement a `Seeder` in
|
|
5
|
+
* its own `seed-*.ts` and append it here — no other change is required.
|
|
6
|
+
*/
|
|
7
|
+
import { Seeder } from './types.js';
|
|
8
|
+
export declare const SEEDERS: Seeder[];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The registry of demo seeders, shared by `respan demo` and `respan demo clear`.
|
|
3
|
+
*
|
|
4
|
+
* To add a service later (traces, datasets, evaluators), implement a `Seeder` in
|
|
5
|
+
* its own `seed-*.ts` and append it here — no other change is required.
|
|
6
|
+
*/
|
|
7
|
+
import { promptsSeeder } from './seed-prompts.js';
|
|
8
|
+
import { datasetsSeeder } from './seed-datasets.js';
|
|
9
|
+
import { evaluatorsSeeder } from './seed-evaluators.js';
|
|
10
|
+
import { tracesSeeder } from './seed-traces.js';
|
|
11
|
+
export const SEEDERS = [promptsSeeder, datasetsSeeder, evaluatorsSeeder, tracesSeeder];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frozen snapshot of the demo traces, supplied by the team as ready-to-POST OTLP v2
|
|
3
|
+
* bodies (the `trace-bodies/` dataset: a 22-span, 6-LLM-call multi-agent AI Service
|
|
4
|
+
* Desk run across two tenants). Source of truth for the traces portion of `respan demo`.
|
|
5
|
+
*
|
|
6
|
+
* Each `body` is a complete OTLP/JSON payload for `POST /api/v2/traces`, captured
|
|
7
|
+
* VERBATIM. The seeder (`seed-traces.ts`) re-stamps fresh trace/span ids and recent
|
|
8
|
+
* timestamps on each fresh seed, and sets the root span's name to `workflow` (below)
|
|
9
|
+
* so it shows in the dashboard Workflow column and is unique per trace — the dataset
|
|
10
|
+
* repeats some scenarios with a byte-identical root span, and a unique root name keeps
|
|
11
|
+
* the platform from rejecting it as a duplicate workflow span.
|
|
12
|
+
*
|
|
13
|
+
* The filterable `span_workflow_name` marker stays a shared constant (see seed-traces.ts)
|
|
14
|
+
* so dedup, teardown, and the dashboard link match all demo traces at once.
|
|
15
|
+
*
|
|
16
|
+
* Frozen snapshot: not regenerated at build time, imported directly. No runtime JSON.
|
|
17
|
+
*/
|
|
18
|
+
/** A complete OTLP/JSON trace body for `POST /api/v2/traces`. */
|
|
19
|
+
export interface OtlpTraceBody {
|
|
20
|
+
resourceSpans: Record<string, unknown>[];
|
|
21
|
+
}
|
|
22
|
+
/** One demo trace. */
|
|
23
|
+
export interface DemoTraceFixture {
|
|
24
|
+
/** Human label for the seed summary. */
|
|
25
|
+
name: string;
|
|
26
|
+
/** Unique workflow id set as the root span name (shown in the Workflow column). */
|
|
27
|
+
workflow: string;
|
|
28
|
+
/** The OTLP body to ingest. */
|
|
29
|
+
body: OtlpTraceBody;
|
|
30
|
+
}
|
|
31
|
+
export declare const DEMO_TRACES: DemoTraceFixture[];
|