coze_lab 0.1.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.
@@ -0,0 +1,442 @@
1
+ import { trace, context, SpanKind, SpanStatusCode } from "@opentelemetry/api";
2
+ import { BasicTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
3
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
4
+ import { Resource } from "@opentelemetry/resources";
5
+ import { ATTR_SERVICE_NAME, ATTR_SERVICE_INSTANCE_ID } from "@opentelemetry/semantic-conventions";
6
+ import { hostname } from "os";
7
+ import { basename, join } from "path";
8
+ import { createRequire } from "node:module";
9
+ import { readFileSync, writeFileSync, mkdirSync } from "fs";
10
+ import { homedir } from "os";
11
+ import https from "https";
12
+
13
+ const require = createRequire(import.meta.url);
14
+ const { version: PLUGIN_VERSION } = require("../package.json");
15
+
16
+ // ── Token refresh helpers ─────────────────────────────────────────────────
17
+ const _CLIENT_ID = "56089404009908161803155625287505.app.coze";
18
+ const _COZE_API = "https://api.coze.cn";
19
+ const _REFRESH_THRESHOLD_MS = 10 * 60 * 1000;
20
+ const _CREDS_PATH = join(homedir(), ".cozeloop", "credentials.json");
21
+
22
+ function _loadCreds() {
23
+ try { return JSON.parse(readFileSync(_CREDS_PATH, "utf8")); }
24
+ catch { return null; }
25
+ }
26
+
27
+ function _saveCreds(c) {
28
+ try {
29
+ mkdirSync(join(homedir(), ".cozeloop"), { recursive: true });
30
+ writeFileSync(_CREDS_PATH, JSON.stringify(c, null, 2), { mode: 0o600 });
31
+ } catch { /* non-fatal */ }
32
+ }
33
+
34
+ async function _refreshToken(refreshTok) {
35
+ return new Promise((resolve) => {
36
+ const body = JSON.stringify({ grant_type: "refresh_token", client_id: _CLIENT_ID, refresh_token: refreshTok });
37
+ const req = https.request(`${_COZE_API}/api/permission/oauth2/token`, {
38
+ method: "POST",
39
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
40
+ }, (res) => {
41
+ let buf = "";
42
+ res.on("data", c => buf += c);
43
+ res.on("end", () => {
44
+ try {
45
+ const d = JSON.parse(buf);
46
+ if (d.access_token) {
47
+ const creds = {
48
+ access_token: d.access_token,
49
+ refresh_token: d.refresh_token ?? refreshTok,
50
+ expires_at: (d.expires_in ?? 0) * 1000, // unix timestamp in seconds
51
+ };
52
+ _saveCreds(creds);
53
+ resolve(creds.access_token);
54
+ } else { resolve(null); }
55
+ } catch { resolve(null); }
56
+ });
57
+ });
58
+ req.on("error", () => resolve(null));
59
+ req.setTimeout(10000, () => { req.destroy(); resolve(null); });
60
+ req.write(body);
61
+ req.end();
62
+ });
63
+ }
64
+
65
+ async function getRefreshedToken(currentAuthorization) {
66
+ const creds = _loadCreds();
67
+ if (!creds) return currentAuthorization; // no creds file, keep as-is
68
+ const remaining = (creds.expires_at ?? 0) - Date.now();
69
+ if (remaining > _REFRESH_THRESHOLD_MS) return `Bearer ${creds.access_token}`;
70
+ if (creds.refresh_token) {
71
+ const newToken = await _refreshToken(creds.refresh_token);
72
+ if (newToken) return `Bearer ${newToken}`;
73
+ }
74
+ return currentAuthorization; // fallback
75
+ }
76
+ // ─────────────────────────────────────────────────────────────────────────
77
+
78
+ export class CozeloopExporter {
79
+ config;
80
+ api;
81
+ provider = null;
82
+ tracer = null;
83
+ initialized = false;
84
+ initPromise = null;
85
+ // Per-trace context: keyed by the plugin-level rootSpanId so that
86
+ // concurrent or overlapping traces never stomp on each other.
87
+ traceContexts = new Map();
88
+ openSpans = new Map();
89
+ // Extra attributes derived from environment variables, applied to every span.
90
+ envAttributes = {};
91
+ constructor(api, config) {
92
+ this.api = api;
93
+ this.config = config;
94
+ this.envAttributes = this.parseEnvAttributes();
95
+ }
96
+ parseEnvAttributes() {
97
+ const attrs = {};
98
+ // COZE_PROJECT_ID -> project_id
99
+ const projectId = process.env.COZE_PROJECT_ID?.trim();
100
+ if (projectId) {
101
+ attrs["project_id"] = projectId;
102
+ }
103
+ // COZELOOP_UDF_TAGS -> udf_ prefixed keys
104
+ const tagsRaw = process.env.COZELOOP_UDF_TAGS?.trim();
105
+ if (tagsRaw) {
106
+ const pairs = tagsRaw.split(",");
107
+ for (const pair of pairs) {
108
+ const eqIdx = pair.indexOf("=");
109
+ if (eqIdx < 0) {
110
+ this.api.logger.error(`[CozeloopTrace] Invalid COZELOOP_UDF_TAGS entry (missing '='): "${pair}"`);
111
+ continue;
112
+ }
113
+ const key = pair.substring(0, eqIdx);
114
+ const value = pair.substring(eqIdx + 1);
115
+ // Validate: key and value must not contain '=' or ','
116
+ if (key.includes("=") || key.includes(",")) {
117
+ this.api.logger.error(`[CozeloopTrace] Invalid COZELOOP_UDF_TAGS key contains '=' or ',': "${key}"`);
118
+ continue;
119
+ }
120
+ if (value.includes("=") || value.includes(",")) {
121
+ this.api.logger.error(`[CozeloopTrace] Invalid COZELOOP_UDF_TAGS value contains '=' or ',': "${value}"`);
122
+ continue;
123
+ }
124
+ if (!key) {
125
+ this.api.logger.error(`[CozeloopTrace] Invalid COZELOOP_UDF_TAGS entry (empty key): "${pair}"`);
126
+ continue;
127
+ }
128
+ attrs[`udf_${key}`] = value;
129
+ }
130
+ }
131
+ return attrs;
132
+ }
133
+ async refreshAuthIfNeeded() {
134
+ const fresh = await getRefreshedToken(this.config.authorization);
135
+ if (fresh && fresh !== this.config.authorization) {
136
+ this.api.logger.info("[CozeloopTrace] Token refreshed, re-initializing exporter...");
137
+ this.config.authorization = fresh;
138
+ // Reset so initialize() re-creates the exporter with the new token
139
+ this.initialized = false;
140
+ this.initPromise = null;
141
+ if (this.provider) {
142
+ try { await this.provider.shutdown(); } catch { /* ignore */ }
143
+ this.provider = null;
144
+ this.tracer = null;
145
+ }
146
+ }
147
+ }
148
+ async ensureInitialized() {
149
+ if (this.initialized)
150
+ return;
151
+ if (this.initPromise)
152
+ return this.initPromise;
153
+ this.initPromise = this.initialize();
154
+ await this.initPromise;
155
+ }
156
+ async initialize() {
157
+ this.api.logger.info(`[CozeloopTrace] Initializing exporter...`);
158
+ const instanceName = this.config.serviceName || basename(process.cwd()) || "openclaw-agent";
159
+ const instanceId = `${instanceName}@${hostname()}:${process.pid}`;
160
+ const resource = new Resource({
161
+ [ATTR_SERVICE_NAME]: this.config.serviceName,
162
+ [ATTR_SERVICE_INSTANCE_ID]: instanceId,
163
+ "host.name": hostname(),
164
+ });
165
+ const authorization = this.config.authorization;
166
+ const workspaceId = this.config.workspaceId;
167
+ this.api.logger.info(`[CozeloopTrace] Using authorization, workspaceId=${workspaceId}, tokenLength=${authorization?.length}`);
168
+ const exporter = new OTLPTraceExporter({
169
+ url: `${this.config.endpoint}/v1/traces`,
170
+ headers: {
171
+ "Authorization": authorization,
172
+ "cozeloop-workspace-id": workspaceId,
173
+ },
174
+ });
175
+ this.provider = new BasicTracerProvider({ resource });
176
+ this.provider.addSpanProcessor(new BatchSpanProcessor(exporter, {
177
+ maxQueueSize: 100,
178
+ maxExportBatchSize: this.config.batchSize || 10,
179
+ scheduledDelayMillis: this.config.batchInterval || 5000,
180
+ }));
181
+ // Do NOT call this.provider.register() — it sets the global TracerProvider
182
+ // singleton, so if the plugin is activated more than once (e.g. gateway +
183
+ // plugins subsystem), the second instance would silently get a NOOP tracer
184
+ // while its hooks override those of the first instance, causing all trace
185
+ // operations to become no-ops. Instead, obtain the tracer directly from
186
+ // our own provider instance.
187
+ this.tracer = this.provider.getTracer("openclaw-cozeloop-trace", PLUGIN_VERSION);
188
+ this.initialized = true;
189
+ this.api.logger.info(`[CozeloopTrace] Exporter initialized with Authorization, workspaceId=${workspaceId}`);
190
+ }
191
+ async startSpan(spanData, spanId) {
192
+ try {
193
+ await this.ensureInitialized();
194
+ this.doStartSpan(spanData, spanId);
195
+ }
196
+ catch (err) {
197
+ this.api.logger.error(`[CozeloopTrace] Failed to start span: ${err}`);
198
+ }
199
+ }
200
+ doStartSpan(spanData, spanId) {
201
+ if (!this.tracer)
202
+ return;
203
+ const spanKind = this.getSpanKind(spanData.type);
204
+ const isRoot = !spanData.parentSpanId;
205
+ const isAgent = spanData.type === "agent";
206
+ // Resolve parent context:
207
+ // - Root spans: no parent, use active context.
208
+ // - Agent/child spans: look up traceContexts by parentSpanId (which is
209
+ // always the rootSpanId set by index.ts createSpan / ensureRootSpan).
210
+ const traceCtx = spanData.parentSpanId
211
+ ? this.traceContexts.get(spanData.parentSpanId)
212
+ : undefined;
213
+ if (!isRoot && !traceCtx && this.config.debug) {
214
+ const keys = Array.from(this.traceContexts.keys());
215
+ this.api.logger.info(`[CozeloopTrace] doStartSpan() cannot find parent context: ` +
216
+ `parentSpanId=${spanData.parentSpanId}, spanName=${spanData.name}, type=${spanData.type}, ` +
217
+ `traceContextKeys=[${keys.join(",")}]`);
218
+ }
219
+ let parentContext;
220
+ if (isRoot) {
221
+ parentContext = context.active();
222
+ }
223
+ else if (isAgent) {
224
+ parentContext = traceCtx?.rootContext || context.active();
225
+ }
226
+ else {
227
+ parentContext = traceCtx?.agentContext || traceCtx?.rootContext || context.active();
228
+ }
229
+ const runtimeTag = {
230
+ language: "nodejs",
231
+ library: "openclaw",
232
+ };
233
+ if (process.env.COZELOOP_SCENE) {
234
+ runtimeTag.scene = process.env.COZELOOP_SCENE;
235
+ }
236
+ const systemTagRuntime = JSON.stringify(runtimeTag);
237
+ const span = this.tracer.startSpan(spanData.name, {
238
+ kind: spanKind,
239
+ startTime: spanData.startTime,
240
+ attributes: {
241
+ "cozeloop.span_type": spanData.type,
242
+ "cozeloop.system_tag_runtime": systemTagRuntime,
243
+ ...this.envAttributes,
244
+ ...this.flattenAttributes(spanData.attributes),
245
+ },
246
+ }, parentContext);
247
+ if (isRoot) {
248
+ const rootContext = trace.setSpan(context.active(), span);
249
+ this.traceContexts.set(spanId, { rootSpan: span, rootContext });
250
+ if (this.config.debug) {
251
+ const sc = span.spanContext();
252
+ this.api.logger.info(`[CozeloopTrace] Created ROOT span: name=${spanData.name}, traceId=${sc.traceId}, spanId=${sc.spanId}`);
253
+ }
254
+ }
255
+ if (isAgent && traceCtx) {
256
+ traceCtx.agentSpan = span;
257
+ traceCtx.agentContext = trace.setSpan(traceCtx.rootContext, span);
258
+ if (this.config.debug) {
259
+ const sc = span.spanContext();
260
+ this.api.logger.info(`[CozeloopTrace] Created AGENT span: name=${spanData.name}, traceId=${sc.traceId}, spanId=${sc.spanId}`);
261
+ }
262
+ }
263
+ this.setSpanInputOutput(span, spanData);
264
+ this.openSpans.set(spanId, span);
265
+ if (this.config.debug && !isRoot && !isAgent) {
266
+ const spanContext = span.spanContext();
267
+ this.api.logger.info(`[CozeloopTrace] Started span: name=${spanData.name}, type=${spanData.type}, ` +
268
+ `traceId=${spanContext.traceId}, spanId=${spanContext.spanId}`);
269
+ }
270
+ }
271
+ endSpanById(spanId, endTime, additionalAttrs, output, input) {
272
+ const span = this.openSpans.get(spanId);
273
+ if (!span) {
274
+ if (this.config.debug) {
275
+ this.api.logger.info(`[CozeloopTrace] Span not found for ending: spanId=${spanId}`);
276
+ }
277
+ return;
278
+ }
279
+ if (additionalAttrs) {
280
+ for (const [key, value] of Object.entries(additionalAttrs)) {
281
+ if (value !== undefined && value !== null) {
282
+ span.setAttribute(key, value);
283
+ }
284
+ }
285
+ }
286
+ if (input !== undefined) {
287
+ const inputStr = typeof input === "string" ? input : JSON.stringify(input);
288
+ span.setAttribute("cozeloop.input", inputStr.substring(0, 3200000));
289
+ }
290
+ if (output !== undefined) {
291
+ const outputStr = typeof output === "string" ? output : JSON.stringify(output);
292
+ span.setAttribute("cozeloop.output", outputStr.substring(0, 3200000));
293
+ }
294
+ span.setStatus({ code: SpanStatusCode.OK });
295
+ span.end(endTime || Date.now());
296
+ this.openSpans.delete(spanId);
297
+ if (this.config.debug) {
298
+ const sc = span.spanContext();
299
+ this.api.logger.info(`[CozeloopTrace] Ended span: spanId=${spanId}, traceId=${sc.traceId}`);
300
+ }
301
+ }
302
+ async export(spanData) {
303
+ try {
304
+ await this.ensureInitialized();
305
+ if (!this.tracer)
306
+ return;
307
+ const spanKind = this.getSpanKind(spanData.type);
308
+ const isRoot = !spanData.parentSpanId;
309
+ const isAgent = spanData.type === "agent";
310
+ const traceCtx = spanData.parentSpanId
311
+ ? this.traceContexts.get(spanData.parentSpanId)
312
+ : undefined;
313
+ if (!isRoot && !traceCtx) {
314
+ // Only warn for span types that are expected to be inside a trace
315
+ // (agent, model, tool). message/session/gateway spans may fire before
316
+ // the root span is created and that is normal.
317
+ const criticalTypes = new Set(["agent", "model", "tool"]);
318
+ if (criticalTypes.has(spanData.type) && this.config.debug) {
319
+ const keys = Array.from(this.traceContexts.keys());
320
+ this.api.logger.info(`[CozeloopTrace] export() cannot find parent context: ` +
321
+ `parentSpanId=${spanData.parentSpanId}, spanName=${spanData.name}, type=${spanData.type}, ` +
322
+ `traceContextKeys=[${keys.join(",")}]`);
323
+ }
324
+ }
325
+ let parentContext;
326
+ if (isRoot) {
327
+ parentContext = context.active();
328
+ }
329
+ else if (isAgent) {
330
+ parentContext = traceCtx?.rootContext || context.active();
331
+ }
332
+ else {
333
+ parentContext = traceCtx?.agentContext || traceCtx?.rootContext || context.active();
334
+ }
335
+ const runtimeTag = {
336
+ language: "nodejs",
337
+ library: "openclaw",
338
+ };
339
+ if (process.env.COZELOOP_SCENE) {
340
+ runtimeTag.scene = process.env.COZELOOP_SCENE;
341
+ }
342
+ const systemTagRuntime = JSON.stringify(runtimeTag);
343
+ const span = this.tracer.startSpan(spanData.name, {
344
+ kind: spanKind,
345
+ startTime: spanData.startTime,
346
+ attributes: {
347
+ "cozeloop.span_type": spanData.type,
348
+ "cozeloop.system_tag_runtime": systemTagRuntime,
349
+ ...this.envAttributes,
350
+ ...this.flattenAttributes(spanData.attributes),
351
+ },
352
+ }, parentContext);
353
+ if (isRoot) {
354
+ const rootContext = trace.setSpan(context.active(), span);
355
+ const spanId = spanData.spanId || "export-root";
356
+ this.traceContexts.set(spanId, { rootSpan: span, rootContext });
357
+ if (this.config.debug) {
358
+ const sc = span.spanContext();
359
+ this.api.logger.info(`[CozeloopTrace] Created ROOT span: name=${spanData.name}, traceId=${sc.traceId}, spanId=${sc.spanId}`);
360
+ }
361
+ }
362
+ this.setSpanInputOutput(span, spanData);
363
+ const hasError = spanData.attributes["error"] === true || spanData.attributes["tool.error"] === true;
364
+ if (hasError) {
365
+ span.setStatus({ code: SpanStatusCode.ERROR });
366
+ }
367
+ else {
368
+ span.setStatus({ code: SpanStatusCode.OK });
369
+ }
370
+ span.end(spanData.endTime || Date.now());
371
+ if (this.config.debug) {
372
+ const spanContext = span.spanContext();
373
+ this.api.logger.info(`[CozeloopTrace] Created span: name=${spanData.name}, type=${spanData.type}, ` +
374
+ `traceId=${spanContext.traceId}, spanId=${spanContext.spanId}, isRoot=${isRoot}`);
375
+ }
376
+ }
377
+ catch (err) {
378
+ this.api.logger.error(`[CozeloopTrace] Failed to export span: ${err}`);
379
+ }
380
+ }
381
+ setSpanInputOutput(span, spanData) {
382
+ if (spanData.input !== undefined) {
383
+ const inputStr = typeof spanData.input === "string"
384
+ ? spanData.input
385
+ : JSON.stringify(spanData.input);
386
+ span.setAttribute("cozeloop.input", inputStr.substring(0, 3200000));
387
+ }
388
+ if (spanData.output !== undefined) {
389
+ const outputStr = typeof spanData.output === "string"
390
+ ? spanData.output
391
+ : JSON.stringify(spanData.output);
392
+ span.setAttribute("cozeloop.output", outputStr.substring(0, 3200000));
393
+ }
394
+ }
395
+ hasTraceContext(rootSpanId) {
396
+ return this.traceContexts.has(rootSpanId);
397
+ }
398
+ endTrace(rootSpanId) {
399
+ if (rootSpanId) {
400
+ this.traceContexts.delete(rootSpanId);
401
+ }
402
+ else {
403
+ this.traceContexts.clear();
404
+ this.openSpans.clear();
405
+ }
406
+ if (this.config.debug) {
407
+ this.api.logger.info(`[CozeloopTrace] Trace ended, context cleared${rootSpanId ? ` for rootSpanId=${rootSpanId}` : ' (all)'}`);
408
+ }
409
+ }
410
+ getSpanKind(type) {
411
+ switch (type) {
412
+ case "entry":
413
+ case "gateway":
414
+ return SpanKind.SERVER;
415
+ case "model":
416
+ return SpanKind.CLIENT;
417
+ case "tool":
418
+ return SpanKind.CLIENT;
419
+ default:
420
+ return SpanKind.INTERNAL;
421
+ }
422
+ }
423
+ flattenAttributes(attrs) {
424
+ const result = {};
425
+ for (const [key, value] of Object.entries(attrs)) {
426
+ if (value !== undefined && value !== null) {
427
+ result[key] = value;
428
+ }
429
+ }
430
+ return result;
431
+ }
432
+ async flush() {
433
+ if (this.provider) {
434
+ await this.provider.forceFlush();
435
+ }
436
+ }
437
+ async dispose() {
438
+ if (this.provider) {
439
+ await this.provider.shutdown();
440
+ }
441
+ }
442
+ }