@volter/supercode-teams 0.2.20
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 +119 -0
- package/attention.mjs +191 -0
- package/bin/fleet.mjs +151 -0
- package/bin/teams.mjs +291 -0
- package/bin/workspace/args.mjs +66 -0
- package/bin/workspace/files.mjs +110 -0
- package/bin/workspace/lifecycle.mjs +113 -0
- package/bin/workspace/volter-device.mjs +42 -0
- package/bin/workspace/volter-login.mjs +185 -0
- package/bin/workspace.mjs +726 -0
- package/browser-attach.mjs +90 -0
- package/client.mjs +212 -0
- package/connector/effects.mjs +95 -0
- package/connector/index.d.ts +69 -0
- package/connector/index.mjs +174 -0
- package/connector/native-continuation.d.ts +8 -0
- package/connector/native-continuation.mjs +50 -0
- package/connector/native-effects.mjs +164 -0
- package/context.mjs +34 -0
- package/contracts/adapter-map.json +16 -0
- package/contracts/index.d.ts +299 -0
- package/contracts/index.mjs +37 -0
- package/execution.mjs +206 -0
- package/fleet.mjs +78 -0
- package/index.mjs +12 -0
- package/machine-door.mjs +36 -0
- package/machine.mjs +1398 -0
- package/package.json +80 -0
- package/porthole-hook.cjs +137 -0
- package/remote/job-exec.py +146 -0
- package/remote/wire.py +23 -0
- package/server/arc2-transactions.mjs +125 -0
- package/server/attachment-relay.mjs +149 -0
- package/server/catalog.mjs +7 -0
- package/server/continuations.mjs +56 -0
- package/server/correlation.d.ts +4 -0
- package/server/correlation.mjs +17 -0
- package/server/effects-transactions.mjs +119 -0
- package/server/effects.mjs +97 -0
- package/server/event-routes.mjs +208 -0
- package/server/exports.mjs +46 -0
- package/server/harness-routes.mjs +248 -0
- package/server/index.d.ts +31 -0
- package/server/index.mjs +52 -0
- package/server/ingest.mjs +24 -0
- package/server/node-upgrade.mjs +39 -0
- package/server/otlp/collector-config.d.ts +2 -0
- package/server/otlp/collector-config.mjs +26 -0
- package/server/otlp/decoders/index.d.ts +63 -0
- package/server/otlp/decoders/index.mjs +311 -0
- package/server/otlp/index.d.ts +4 -0
- package/server/otlp/index.mjs +4 -0
- package/server/otlp/projection.d.ts +5 -0
- package/server/otlp/projection.mjs +17 -0
- package/server/otlp/receiver.d.ts +3 -0
- package/server/otlp/receiver.mjs +36 -0
- package/server/otlp/schema/google/protobuf/any.proto +162 -0
- package/server/otlp/schema/google/rpc/status.proto +48 -0
- package/server/otlp/schema/manifest.json +22 -0
- package/server/otlp/schema/opentelemetry/proto/collector/logs/v1/logs_service.proto +77 -0
- package/server/otlp/schema/opentelemetry/proto/collector/metrics/v1/metrics_service.proto +77 -0
- package/server/otlp/schema/opentelemetry/proto/collector/trace/v1/trace_service.proto +77 -0
- package/server/otlp/schema/opentelemetry/proto/common/v1/common.proto +154 -0
- package/server/otlp/schema/opentelemetry/proto/logs/v1/logs.proto +226 -0
- package/server/otlp/schema/opentelemetry/proto/metrics/v1/metrics.proto +735 -0
- package/server/otlp/schema/opentelemetry/proto/resource/v1/resource.proto +45 -0
- package/server/otlp/schema/opentelemetry/proto/trace/v1/trace.proto +359 -0
- package/server/otlp/transport.d.ts +10 -0
- package/server/otlp/transport.mjs +424 -0
- package/server/otlp/wire-codec.mjs +153 -0
- package/server/store-core.mjs +139 -0
- package/server/store-worker.mjs +23 -0
- package/worker/fetch-adapter.mjs +49 -0
- package/worker/index.mjs +127 -0
- package/worker/websocket-duplex.mjs +29 -0
- package/worker/wrangler.toml +38 -0
- package/workspace-client.d.ts +75 -0
- package/workspace-client.mjs +171 -0
- package/workspace-move.mjs +38 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface TeamsClock {
|
|
2
|
+
nowMs(): number;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export interface TeamsIdSource {
|
|
6
|
+
next(kind: string): string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface TeamsServerOptions {
|
|
10
|
+
dataDir: string;
|
|
11
|
+
listen?: { host?: string; port?: number };
|
|
12
|
+
publicOrigin?: string | null;
|
|
13
|
+
clock?: TeamsClock;
|
|
14
|
+
ids?: TeamsIdSource;
|
|
15
|
+
initTeam?: string;
|
|
16
|
+
/** Artifact cap in bytes. Defaults to the protocol maximum of 256 MiB. */
|
|
17
|
+
uploadLimitBytes?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface StartedTeamsServer {
|
|
21
|
+
address: string;
|
|
22
|
+
server_id: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TeamsServer {
|
|
26
|
+
readonly serverId: string | undefined;
|
|
27
|
+
start(): Promise<StartedTeamsServer>;
|
|
28
|
+
close(): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export declare function createTeamsServer(options: TeamsServerOptions): TeamsServer;
|
package/server/index.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The Node host: one process on a data directory (SQLite in a worker thread, the
|
|
2
|
+
// bootstrap credential in a protected file beside it), serving HTTP and the
|
|
3
|
+
// machine doors on one listener. The server itself is core.mjs's.
|
|
4
|
+
import { identityOrganizationsFromEnv } from '@volter/teams/server/identity-orgs';
|
|
5
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
6
|
+
import { mkdirSync, openSync, closeSync, writeFileSync, readFileSync, renameSync, chmodSync, unlinkSync, existsSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { randomBytes } from 'node:crypto';
|
|
9
|
+
import { TeamsStore } from './store-worker.mjs';
|
|
10
|
+
import { makeToken } from '@volter/teams/server/auth';
|
|
11
|
+
import { createHttpHandler, authenticate } from '@volter/teams/server/http';
|
|
12
|
+
import { harnessRoutes } from './harness-routes.mjs';
|
|
13
|
+
import { createMachineUpgradeHandler } from './node-upgrade.mjs';
|
|
14
|
+
import { closeAttachmentRelays } from './attachment-relay.mjs';
|
|
15
|
+
import { createTeamsApi, bootstrapServer, maintain, normalizePublicOrigin, productionClock, productionIds } from '@volter/teams/server/core';
|
|
16
|
+
|
|
17
|
+
function atomicJson(path,value){const tmp=`${path}.${process.pid}.${randomBytes(12).toString('hex')}.tmp`;let fd;try{fd=openSync(tmp,'wx',0o600);writeFileSync(fd,JSON.stringify(value,null,2)+'\n');closeSync(fd);fd=undefined;renameSync(tmp,path);chmodSync(path,0o600);}catch(error){if(fd!==undefined)closeSync(fd);try{unlinkSync(tmp);}catch{}throw error;}}
|
|
18
|
+
|
|
19
|
+
export function createTeamsServer({dataDir,listen={host:'127.0.0.1',port:7331},publicOrigin=null,identityIssuer=null,delegatedIssuer=null,clock=productionClock,ids=productionIds,initTeam,uploadLimitBytes=256*1024*1024}={}) {
|
|
20
|
+
if(!dataDir) throw new TypeError('dataDir is required');
|
|
21
|
+
const loopback=['127.0.0.1','::1','localhost'].includes(listen.host??'127.0.0.1');
|
|
22
|
+
publicOrigin=normalizePublicOrigin(publicOrigin);
|
|
23
|
+
if(!loopback&&!publicOrigin?.startsWith('https:'))throw new TypeError('non-loopback Teams servers require an HTTPS publicOrigin');
|
|
24
|
+
const api=createTeamsApi({clock,ids,publicOrigin,identityIssuer,identityOrganizations:identityIssuer?identityOrganizationsFromEnv(process.env):null,delegatedIssuer,uploadLimitBytes});
|
|
25
|
+
// The harness's own routes on this server (harness-routes.mjs); the handler owns the neutral ones.
|
|
26
|
+
api.attach({harnessRoutes});
|
|
27
|
+
const events=api.events;
|
|
28
|
+
let store,http,upgrade,lockFd,maintenanceTimer,started=false;
|
|
29
|
+
api.start=async()=>{
|
|
30
|
+
if(started)throw new Error('Teams server already started'); mkdirSync(dataDir,{recursive:true,mode:0o700});chmodSync(dataDir,0o700);
|
|
31
|
+
const lock=join(dataDir,'server.lock'); const acquire=()=>{lockFd=openSync(lock,'wx',0o600);writeFileSync(lockFd,JSON.stringify({pid:process.pid,started_at:clock.nowMs()}));};try{acquire();}catch(e){let recover=false;try{const owner=JSON.parse(readFileSync(lock,'utf8'));if(Number.isInteger(owner.pid)&&owner.pid>1){try{process.kill(owner.pid,0);}catch(error){recover=error.code==='ESRCH';}}}catch{}if(!recover)throw new Error(`Teams data directory is already owned: ${dataDir}`,{cause:e});unlinkSync(lock);acquire();}
|
|
32
|
+
try { const dbPath=join(dataDir,'teams.sqlite'); store=new TeamsStore(dbPath); api.attach({store});
|
|
33
|
+
const existing=await store.one("SELECT value FROM server_meta WHERE key='server_id'");
|
|
34
|
+
const credentialPath=join(dataDir,'bootstrap-credential.json'),pendingPath=join(dataDir,'bootstrap-credential.pending.json');
|
|
35
|
+
let serverId;
|
|
36
|
+
if(!existing){
|
|
37
|
+
let bootstrap;if(existsSync(pendingPath))bootstrap=JSON.parse(readFileSync(pendingPath,'utf8'));else{const cid=ids.next('credential');bootstrap={version:1,server_id:ids.next('server'),team_id:ids.next('team'),principal_id:ids.next('user'),credential_id:cid,token:makeToken(cid)};atomicJson(pendingPath,bootstrap);}
|
|
38
|
+
serverId=await bootstrapServer(api,{initTeam,bootstrap});
|
|
39
|
+
renameSync(pendingPath,credentialPath);chmodSync(credentialPath,0o600);
|
|
40
|
+
} else {
|
|
41
|
+
if(initTeam!==undefined)throw new Error('initTeam is only valid when creating a new Teams data directory');
|
|
42
|
+
serverId=existing.value;
|
|
43
|
+
if(!existsSync(credentialPath)&&existsSync(pendingPath)){const pending=JSON.parse(readFileSync(pendingPath,'utf8'));if(pending.server_id!==serverId)throw new Error('bootstrap credential recovery does not match server identity');renameSync(pendingPath,credentialPath);chmodSync(credentialPath,0o600);}
|
|
44
|
+
}
|
|
45
|
+
api.attach({serverId});
|
|
46
|
+
await maintain(api);maintenanceTimer=setInterval(()=>{void maintain(api).catch(()=>{});},60000);maintenanceTimer.unref?.();
|
|
47
|
+
chmodSync(dbPath,0o600); http=createHttpServer(createHttpHandler(api));upgrade=createMachineUpgradeHandler(api,{authenticate});http.on('upgrade',upgrade); await new Promise((resolve,reject)=>{http.once('error',reject);http.listen(listen.port??0,listen.host??'127.0.0.1',resolve);});started=true;const addr=http.address();api.attach({localOrigin:`http://${addr.address.includes(':')?`[${addr.address}]`:addr.address}:${addr.port}`});return {address:`${addr.address}:${addr.port}`,server_id:serverId};
|
|
48
|
+
} catch(e){if(maintenanceTimer){clearInterval(maintenanceTimer);maintenanceTimer=undefined;}await store?.close().catch(()=>{});store=null;api.attach({store:null});if(lockFd!==undefined){closeSync(lockFd);lockFd=undefined;}try{unlinkSync(lock);}catch{}throw e;}
|
|
49
|
+
};
|
|
50
|
+
api.close=async()=>{if(!started&&lockFd===undefined)return;started=false;if(maintenanceTimer){clearInterval(maintenanceTimer);maintenanceTimer=undefined;}events.close();closeAttachmentRelays(api,'access_revoked');upgrade?.close();await new Promise(resolve=>http?.close(()=>resolve()));await store?.close();store=null;if(lockFd!==undefined){closeSync(lockFd);lockFd=undefined;}try{unlinkSync(join(dataDir,'server.lock'));}catch{}api.attach({store:null});};
|
|
51
|
+
return api;
|
|
52
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { canonicalJson, ContractError, sha256 } from '@volter/teams/server/wire';
|
|
3
|
+
export { sha256 };
|
|
4
|
+
import { createOtlpHttpReceiver } from './otlp/receiver.mjs';
|
|
5
|
+
import { encodeOtlpError } from './otlp/transport.mjs';
|
|
6
|
+
import { projectOtlpBatch } from './otlp/projection.mjs';
|
|
7
|
+
export function captureDigest(begin, parts, commit) { const copy={...commit}; delete copy.digest; return sha256(canonicalJson({begin,parts,commit:copy})); }
|
|
8
|
+
export function validateArtifactName(name) { if(typeof name!=='string'||!name||name.includes('\\')||name.includes('\0')||name.startsWith('/')||name.split('/').includes('..')) throw new ContractError('artifact relative_name is not portable'); }
|
|
9
|
+
export function captureMessages(parts) { const keys=new Set(); const out=[]; for(const part of parts)for(const message of part.messages){if(keys.has(message.native_key))throw Object.assign(new Error('duplicate native message key'),{code:'capture_conflict'});keys.add(message.native_key);out.push(message);} return out; }
|
|
10
|
+
|
|
11
|
+
export async function handleOtlpRoute(api, req, res, auth, signal) {
|
|
12
|
+
const representation=String(req.headers['content-type']??'').split(';',1)[0].trim().toLowerCase()==='application/x-protobuf'?'protobuf':'json';
|
|
13
|
+
const write=output=>{res.writeHead(output.status,{...output.headers,'cache-control':'no-store'});res.end(output.body);};
|
|
14
|
+
if(!auth||auth.kind!=='ingest'||!auth.team_id||!auth.source_id){const body=await encodeOtlpError({representation,code:16,message:'valid ingestion credentials are required'});return write({status:401,headers:{'content-type':representation==='protobuf'?'application/x-protobuf':'application/json'},body});}
|
|
15
|
+
const rateKey=`otlp:${auth.credential_id}`,rateNow=api.clock.nowMs(),rate=api.rateState.get(rateKey)??{tokens:30,at:rateNow};rate.tokens=Math.min(30,rate.tokens+(rateNow-rate.at)*(120/60000));rate.at=rateNow;if(rate.tokens<1){api.rateState.set(rateKey,rate);const body=await encodeOtlpError({representation,code:8,message:'ingestion rate limit exceeded'});return write({status:429,headers:{'content-type':representation==='protobuf'?'application/x-protobuf':'application/json','retry-after':'1'},body});}rate.tokens-=1;api.rateState.set(rateKey,rate);
|
|
16
|
+
const receivedAt=api.clock.nowMs();
|
|
17
|
+
const receiver=createOtlpHttpReceiver({persist:async({records,rejectedCount,sourceAuthorization})=>{
|
|
18
|
+
const items=projectOtlpBatch(records,{teamId:sourceAuthorization.teamId,sourceId:sourceAuthorization.sourceId,ids:api.ids});
|
|
19
|
+
const receipt=await api.store.ingestOtlpBatch({teamId:sourceAuthorization.teamId,sourceId:sourceAuthorization.sourceId,credentialId:sourceAuthorization.credentialId,receivedAt,authorizationCheckedAt:api.clock.nowMs(),items,rejectedCount});
|
|
20
|
+
for(const changed of receipt.changedSessions)api.events.publish(sourceAuthorization.teamId,{type:'session_changed',cursor:String(changed.revision),revision:changed.revision,session_id:changed.sessionId,reason:'capture',read_required:true});
|
|
21
|
+
}});
|
|
22
|
+
try { return write(await receiver({signal,headers:req.headers,body:req,sourceAuthorization:{teamId:auth.team_id,sourceId:auth.source_id,credentialId:auth.credential_id},receivedAt})); }
|
|
23
|
+
catch(error){const status=error.status===401?401:503,code=status===401?16:14,message=status===401?'ingestion authorization changed':'ingestion commit did not complete';const body=await encodeOtlpError({representation,code,message});return write({status,headers:{'content-type':representation==='protobuf'?'application/x-protobuf':'application/json'},body});}
|
|
24
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The Node host's WebSocket termination for the machine doors (machine-link.mjs):
|
|
2
|
+
// the `ws` library upgrades, pings every 20 s, and hands the socket over as a stream.
|
|
3
|
+
import { WebSocketServer, createWebSocketStream } from 'ws';
|
|
4
|
+
import { authorizeMachineUpgrade, acceptMachineSocket, LINK_PROTOCOL, CHANNEL_PROTOCOL } from '@volter/teams/server/machine-link';
|
|
5
|
+
|
|
6
|
+
const MAX_PAYLOAD = 16 * 1024 * 1024;
|
|
7
|
+
const PING_MS = 20_000;
|
|
8
|
+
|
|
9
|
+
/** The Node host's `upgrade` handler: the `ws` library terminates the WebSocket. */
|
|
10
|
+
export function createMachineUpgradeHandler(api, { authenticate }) {
|
|
11
|
+
const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_PAYLOAD, handleProtocols: (offered) => [...offered].find((p) => p === LINK_PROTOCOL || p === CHANNEL_PROTOCOL) ?? false });
|
|
12
|
+
const heartbeat = setInterval(() => {
|
|
13
|
+
for (const client of wss.clients) {
|
|
14
|
+
if (client.alive === false) { client.terminate(); continue; }
|
|
15
|
+
client.alive = false;
|
|
16
|
+
client.ping();
|
|
17
|
+
}
|
|
18
|
+
}, PING_MS);
|
|
19
|
+
heartbeat.unref?.();
|
|
20
|
+
const refuse = (socket, status, code, message) => {
|
|
21
|
+
const body = JSON.stringify({ error: { code, message } });
|
|
22
|
+
socket.end(`HTTP/1.1 ${status} ${code}\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`);
|
|
23
|
+
};
|
|
24
|
+
const handler = async (req, socket, head) => {
|
|
25
|
+
try {
|
|
26
|
+
const decision = await authorizeMachineUpgrade(api, { authenticate, url: req.url, headers: req.headers });
|
|
27
|
+
if (decision.status) return refuse(socket, decision.status, decision.code, decision.message);
|
|
28
|
+
return wss.handleUpgrade(req, socket, head, (ws) => {
|
|
29
|
+
ws.alive = true; ws.on('pong', () => { ws.alive = true; });
|
|
30
|
+
void acceptMachineSocket(api, decision, createWebSocketStream(ws), (reason) => ws.close(1000, String(reason ?? 'closed').slice(0, 100)));
|
|
31
|
+
});
|
|
32
|
+
} catch (error) {
|
|
33
|
+
refuse(socket, 500, 'internal', error.message);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
handler.close = () => { clearInterval(heartbeat); for (const client of wss.clients) client.terminate(); wss.close(); };
|
|
37
|
+
return handler;
|
|
38
|
+
}
|
|
39
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const SOURCE_ID = /^src_([0-9a-f]{32})$/;
|
|
2
|
+
|
|
3
|
+
export function renderSourceCollectorConfig({ sourceId, endpoint }) {
|
|
4
|
+
const match = SOURCE_ID.exec(sourceId);
|
|
5
|
+
if (!match) throw new TypeError("sourceId must be src_ plus 32 lowercase hexadecimal digits");
|
|
6
|
+
let origin;
|
|
7
|
+
try { origin = new URL(endpoint); } catch { throw new TypeError("endpoint must be a trusted HTTP(S) origin"); }
|
|
8
|
+
if (!/^https?:$/.test(origin.protocol) || origin.username || origin.password || origin.pathname !== "/" || origin.search || origin.hash) {
|
|
9
|
+
throw new TypeError("endpoint must be a trusted HTTP(S) origin without credentials, path, query, or fragment");
|
|
10
|
+
}
|
|
11
|
+
const normalizedEndpoint = origin.origin;
|
|
12
|
+
const credentialEnv = `SUPERCODE_TEAMS_INGEST_TOKEN_${match[1].toUpperCase()}`;
|
|
13
|
+
const exporterId = `otlphttp/supercode_${sourceId}`;
|
|
14
|
+
const content = [
|
|
15
|
+
"# Add this exporter to your existing OpenTelemetry Collector configuration.",
|
|
16
|
+
"# Append its ID to every logs/traces/metrics pipeline that should reach Harness Teams.",
|
|
17
|
+
"# Keep all existing receivers, exporters, and pipelines; this fragment does not replace them.",
|
|
18
|
+
"exporters:",
|
|
19
|
+
` ${exporterId}:`,
|
|
20
|
+
` endpoint: ${normalizedEndpoint}`,
|
|
21
|
+
" headers:",
|
|
22
|
+
` authorization: "Bearer \${env:${credentialEnv}}"`,
|
|
23
|
+
""
|
|
24
|
+
].join("\n");
|
|
25
|
+
return { format: "otel-collector", source_id: sourceId, endpoint: normalizedEndpoint, credential_env: credentialEnv, content };
|
|
26
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { HarnessId, NormalizedMessage } from '@volter/supercode-harness-sdk';
|
|
2
|
+
|
|
3
|
+
export type OtlpAnyValue =
|
|
4
|
+
| { stringValue: string }
|
|
5
|
+
| { boolValue: boolean }
|
|
6
|
+
| { intValue: string }
|
|
7
|
+
| { doubleValue: number }
|
|
8
|
+
| { bytesValue: string }
|
|
9
|
+
| { arrayValue: { values: OtlpAnyValue[] } }
|
|
10
|
+
| { kvlistValue: { values: OtlpKeyValue[] } };
|
|
11
|
+
export interface OtlpKeyValue { key: string; value: OtlpAnyValue }
|
|
12
|
+
export type AttributeInput = OtlpKeyValue[] | Record<string, unknown>;
|
|
13
|
+
export interface OtlpLogRecord {
|
|
14
|
+
eventName?: string;
|
|
15
|
+
timeUnixNano?: string;
|
|
16
|
+
observedTimeUnixNano?: string;
|
|
17
|
+
traceId?: string;
|
|
18
|
+
spanId?: string;
|
|
19
|
+
/** Raw OTLP AnyValue, or a string from an upstream schema decoder. */
|
|
20
|
+
body?: OtlpAnyValue | string;
|
|
21
|
+
attributes?: AttributeInput;
|
|
22
|
+
[unknownField: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
export interface DecodeLogEvidenceInput {
|
|
25
|
+
record: OtlpLogRecord;
|
|
26
|
+
resourceAttributes?: AttributeInput;
|
|
27
|
+
scopeAttributes?: AttributeInput;
|
|
28
|
+
receivedAt: number;
|
|
29
|
+
}
|
|
30
|
+
export type DecodeIssueCode =
|
|
31
|
+
| 'invalid_record' | 'invalid_received_at' | 'invalid_attributes' | 'invalid_attribute'
|
|
32
|
+
| 'duplicate_attribute' | 'invalid_any_value' | 'invalid_int64' | 'int64_out_of_range'
|
|
33
|
+
| 'invalid_bytes_value' | 'invalid_attribute_type'
|
|
34
|
+
| 'invalid_event_name' | 'event_name_disagreement' | 'invalid_timestamp'
|
|
35
|
+
| 'uint64_out_of_range' | 'timestamp_out_of_range' | 'invalid_trace_id' | 'invalid_span_id';
|
|
36
|
+
export interface DecodeIssue { code: DecodeIssueCode; path: string; message: string }
|
|
37
|
+
export type CoverageReason =
|
|
38
|
+
| 'redacted' | 'truncated' | 'unsequenced' | 'unsupported_decoder' | 'parse_error';
|
|
39
|
+
export interface EvidenceIdentityFacts {
|
|
40
|
+
native_event_id: string | null;
|
|
41
|
+
trace_id: string | null;
|
|
42
|
+
span_id: string | null;
|
|
43
|
+
event_index: string | null;
|
|
44
|
+
dedup_eligible: boolean;
|
|
45
|
+
}
|
|
46
|
+
export interface DecodedLogEvidence {
|
|
47
|
+
raw: DecodeLogEvidenceInput;
|
|
48
|
+
decoder: 'claude-code.logs.v1' | 'gemini.logs.v1' | 'codex.logs.v1' | 'grok.logs.v1' | null;
|
|
49
|
+
status: 'decoded' | 'activity' | 'unassociated' | 'decode_error';
|
|
50
|
+
event_name: string | null;
|
|
51
|
+
harness: Extract<HarnessId, 'claude-code' | 'gemini' | 'codex' | 'grok'> | null;
|
|
52
|
+
native_session_id: string | null;
|
|
53
|
+
/** Unverified vendor-reported identity; never a Teams principal mapping. */
|
|
54
|
+
reported_user: string | null;
|
|
55
|
+
recorded_at: number | null;
|
|
56
|
+
recorded_time_unix_nano: string | null;
|
|
57
|
+
message: NormalizedMessage | null;
|
|
58
|
+
coverage_reasons: CoverageReason[];
|
|
59
|
+
identity: EvidenceIdentityFacts | null;
|
|
60
|
+
errors: DecodeIssue[];
|
|
61
|
+
}
|
|
62
|
+
export declare function decodeLogEvidence(input: DecodeLogEvidenceInput): DecodedLogEvidence;
|
|
63
|
+
export declare const DECODERS: Readonly<{ claude: 'claude-code.logs.v1'; gemini: 'gemini.logs.v1'; codex: 'codex.logs.v1'; grok: 'grok.logs.v1' }>;
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
const DECODERS = Object.freeze({
|
|
2
|
+
claude: 'claude-code.logs.v1',
|
|
3
|
+
gemini: 'gemini.logs.v1',
|
|
4
|
+
codex: 'codex.logs.v1',
|
|
5
|
+
grok: 'grok.logs.v1',
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
// Per decoder: the event-name prefix that selects it, the harness it attributes records to, the
|
|
9
|
+
// log attribute that carries the harness's native session id, and whether LogRecord.eventName is
|
|
10
|
+
// a tracing callsite label ("event <file>:<line>") rather than the event, so only event.name names it.
|
|
11
|
+
const DECODER_FACTS = Object.freeze({
|
|
12
|
+
[DECODERS.claude]: { prefix: 'claude_code.', harness: 'claude-code', session: 'session.id', callsiteEventName: false },
|
|
13
|
+
[DECODERS.gemini]: { prefix: 'gemini_cli.', harness: 'gemini', session: 'session.id', callsiteEventName: false },
|
|
14
|
+
// codex-rs/otel/src/events/shared.rs (log_event!) + otel/src/provider.rs (OpenTelemetryTracingBridge) @ rust-v0.156.1.
|
|
15
|
+
[DECODERS.codex]: { prefix: 'codex.', harness: 'codex', session: 'conversation.id', callsiteEventName: true },
|
|
16
|
+
// grok 1.0.41 bundled docs 24-monitoring-usage.md, "Events (OTLP log records)": every event carries session.id.
|
|
17
|
+
[DECODERS.grok]: { prefix: 'grok_code.', harness: 'grok', session: 'session.id', callsiteEventName: false },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const MESSAGE_EVENTS = Object.freeze({
|
|
21
|
+
'claude_code.user_prompt': { decoder: DECODERS.claude, harness: 'claude-code', role: 'user', content: 'prompt' },
|
|
22
|
+
'claude_code.assistant_response': { decoder: DECODERS.claude, harness: 'claude-code', role: 'assistant', content: 'response' },
|
|
23
|
+
'gemini_cli.user_prompt': { decoder: DECODERS.gemini, harness: 'gemini', role: 'user', content: 'prompt' },
|
|
24
|
+
'gemini_cli.api_response': { decoder: DECODERS.gemini, harness: 'gemini', role: 'assistant', content: 'response_text' },
|
|
25
|
+
// codex-rs/otel/src/events/session_telemetry.rs (SessionTelemetry::user_prompt) @ rust-v0.156.1; Codex logs no assistant text.
|
|
26
|
+
'codex.user_prompt': { decoder: DECODERS.codex, harness: 'codex', role: 'user', content: 'prompt' },
|
|
27
|
+
// grok 1.0.41 bundled docs 24-monitoring-usage.md: `prompt` present only under OTEL_LOG_USER_PROMPTS.
|
|
28
|
+
'grok_code.user_prompt': { decoder: DECODERS.grok, harness: 'grok', role: 'user', content: 'prompt' },
|
|
29
|
+
// grok 1.0.41 bundled docs 24-monitoring-usage.md: `response` present only under OTEL_LOG_ASSISTANT_RESPONSES.
|
|
30
|
+
'grok_code.assistant_response': { decoder: DECODERS.grok, harness: 'grok', role: 'assistant', content: 'response' },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const DECIMAL_INT64 = /^-?(?:0|[1-9][0-9]*)$/;
|
|
34
|
+
const INT64_MIN = -9_223_372_036_854_775_808n;
|
|
35
|
+
const INT64_MAX = 9_223_372_036_854_775_807n;
|
|
36
|
+
const UINT64_MAX = 18_446_744_073_709_551_615n;
|
|
37
|
+
const HEX_TRACE = /^[0-9a-fA-F]{32}$/;
|
|
38
|
+
const HEX_SPAN = /^[0-9a-fA-F]{16}$/;
|
|
39
|
+
const ZERO_TRACE = '0'.repeat(32);
|
|
40
|
+
const ZERO_SPAN = '0'.repeat(16);
|
|
41
|
+
const BASE64 = /^[A-Za-z0-9+/_-]*={0,2}$/;
|
|
42
|
+
const REDACTION_MARKERS = new Set(['<REDACTED>', '[REDACTED]']);
|
|
43
|
+
const TRUNCATION_MARKERS = ['<TRUNCATED>', '[TRUNCATED]'];
|
|
44
|
+
|
|
45
|
+
function issue(code, path, message) {
|
|
46
|
+
return { code, path, message };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function decodeAnyValue(value, path, errors) {
|
|
50
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
51
|
+
errors.push(issue('invalid_any_value', path, 'AnyValue must be an object'));
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
const fields = ['stringValue', 'boolValue', 'intValue', 'doubleValue', 'bytesValue', 'arrayValue', 'kvlistValue'];
|
|
55
|
+
const present = fields.filter((field) => Object.hasOwn(value, field));
|
|
56
|
+
if (present.length !== 1) {
|
|
57
|
+
errors.push(issue('invalid_any_value', path, 'AnyValue must contain exactly one value field'));
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
const field = present[0];
|
|
61
|
+
const raw = value[field];
|
|
62
|
+
if (field === 'stringValue') {
|
|
63
|
+
if (typeof raw !== 'string') errors.push(issue('invalid_any_value', `${path}.stringValue`, 'must be a string'));
|
|
64
|
+
return typeof raw === 'string' ? raw : undefined;
|
|
65
|
+
}
|
|
66
|
+
if (field === 'boolValue') {
|
|
67
|
+
if (typeof raw !== 'boolean') errors.push(issue('invalid_any_value', `${path}.boolValue`, 'must be a boolean'));
|
|
68
|
+
return typeof raw === 'boolean' ? raw : undefined;
|
|
69
|
+
}
|
|
70
|
+
if (field === 'intValue') {
|
|
71
|
+
if (typeof raw !== 'string' || !DECIMAL_INT64.test(raw)) {
|
|
72
|
+
errors.push(issue('invalid_int64', `${path}.intValue`, 'OTLP int64 must be a canonical decimal string'));
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
// A signed int64 has at most 19 digits (plus a sign); reject longer input before BigInt parsing.
|
|
76
|
+
if (raw.length > 20 || (raw[0] !== '-' && raw.length > 19)) {
|
|
77
|
+
errors.push(issue('int64_out_of_range', `${path}.intValue`, 'OTLP int64 is outside the signed 64-bit range'));
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
const parsed = BigInt(raw);
|
|
81
|
+
if (parsed < INT64_MIN || parsed > INT64_MAX) {
|
|
82
|
+
errors.push(issue('int64_out_of_range', `${path}.intValue`, 'OTLP int64 is outside the signed 64-bit range'));
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
return raw;
|
|
86
|
+
}
|
|
87
|
+
if (field === 'doubleValue') {
|
|
88
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw)) errors.push(issue('invalid_any_value', `${path}.doubleValue`, 'must be a finite number'));
|
|
89
|
+
return typeof raw === 'number' && Number.isFinite(raw) ? raw : undefined;
|
|
90
|
+
}
|
|
91
|
+
if (field === 'bytesValue') {
|
|
92
|
+
const padding = typeof raw === 'string' ? raw.length - raw.replace(/=+$/, '').length : 0;
|
|
93
|
+
const dataLength = typeof raw === 'string' ? raw.length - padding : 0;
|
|
94
|
+
const validLength = padding > 0 ? raw.length % 4 === 0 : dataLength % 4 !== 1;
|
|
95
|
+
if (typeof raw !== 'string' || !BASE64.test(raw) || !validLength) {
|
|
96
|
+
errors.push(issue('invalid_bytes_value', `${path}.bytesValue`, 'must be valid standard or URL-safe base64'));
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
return raw;
|
|
100
|
+
}
|
|
101
|
+
if (field === 'arrayValue') {
|
|
102
|
+
if (!raw || typeof raw !== 'object' || !Array.isArray(raw.values)) {
|
|
103
|
+
errors.push(issue('invalid_any_value', `${path}.arrayValue`, 'must contain a values array'));
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
return raw.values.map((entry, index) => decodeAnyValue(entry, `${path}.arrayValue.values[${index}]`, errors));
|
|
107
|
+
}
|
|
108
|
+
if (!raw || typeof raw !== 'object' || !Array.isArray(raw.values)) {
|
|
109
|
+
errors.push(issue('invalid_any_value', `${path}.kvlistValue`, 'must contain a values array'));
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
return decodeAttributes(raw.values, `${path}.kvlistValue.values`, errors);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function decodeAttributes(attributes, path, errors) {
|
|
116
|
+
if (attributes === undefined || attributes === null) return {};
|
|
117
|
+
if (!Array.isArray(attributes)) {
|
|
118
|
+
if (typeof attributes === 'object') return { ...attributes };
|
|
119
|
+
errors.push(issue('invalid_attributes', path, 'attributes must be an OTLP KeyValue array or object'));
|
|
120
|
+
return {};
|
|
121
|
+
}
|
|
122
|
+
const result = Object.create(null);
|
|
123
|
+
for (let index = 0; index < attributes.length; index += 1) {
|
|
124
|
+
const entry = attributes[index];
|
|
125
|
+
const entryPath = `${path}[${index}]`;
|
|
126
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry) || typeof entry.key !== 'string' || !entry.key) {
|
|
127
|
+
errors.push(issue('invalid_attribute', entryPath, 'attribute must have a non-empty string key'));
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (Object.hasOwn(result, entry.key)) {
|
|
131
|
+
errors.push(issue('duplicate_attribute', `${entryPath}.key`, `duplicate attribute ${entry.key}`));
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
Object.defineProperty(result, entry.key, {
|
|
135
|
+
value: decodeAnyValue(entry.value, `${entryPath}.value`, errors),
|
|
136
|
+
enumerable: true,
|
|
137
|
+
configurable: true,
|
|
138
|
+
writable: true,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function normalizeName(name) {
|
|
145
|
+
if (name === 'user_prompt') return 'claude_code.user_prompt';
|
|
146
|
+
if (name === 'assistant_response') return 'claude_code.assistant_response';
|
|
147
|
+
return name;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function decoderFor(name) {
|
|
151
|
+
if (!name) return null;
|
|
152
|
+
return Object.keys(DECODER_FACTS).find((decoder) => name.startsWith(DECODER_FACTS[decoder].prefix)) ?? null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function milliseconds(raw, path, errors) {
|
|
156
|
+
if (raw === undefined || raw === null || raw === '0') return null;
|
|
157
|
+
if (typeof raw !== 'string' || !/^(?:0|[1-9][0-9]*)$/.test(raw)) {
|
|
158
|
+
errors.push(issue('invalid_timestamp', path, 'OTLP nanoseconds must be a non-negative canonical decimal string'));
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
if (raw.length > 20) {
|
|
162
|
+
errors.push(issue('uint64_out_of_range', path, 'OTLP nanoseconds exceed the unsigned 64-bit range'));
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
const nanos = BigInt(raw);
|
|
166
|
+
if (nanos > UINT64_MAX) {
|
|
167
|
+
errors.push(issue('uint64_out_of_range', path, 'OTLP nanoseconds exceed the unsigned 64-bit range'));
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
const value = nanos / 1_000_000n;
|
|
171
|
+
if (value > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
172
|
+
errors.push(issue('timestamp_out_of_range', path, 'millisecond timestamp exceeds Number.MAX_SAFE_INTEGER'));
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
return Number(value);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function stringAttribute(attributes, key, errors) {
|
|
179
|
+
const value = attributes[key];
|
|
180
|
+
if (value === undefined) return null;
|
|
181
|
+
if (typeof value !== 'string') {
|
|
182
|
+
errors.push(issue('invalid_attribute_type', `attributes.${key}`, `${key} must be a string`));
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
return value;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function coverageForContent(content) {
|
|
189
|
+
if (content === null) return [];
|
|
190
|
+
if (REDACTION_MARKERS.has(content)) return ['redacted'];
|
|
191
|
+
if (TRUNCATION_MARKERS.some((marker) => content.includes(marker))) return ['truncated'];
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function identityFacts(record, attributes, eventName, errors) {
|
|
196
|
+
const traceId = record.traceId === '' ? null : record.traceId ?? null;
|
|
197
|
+
const spanId = record.spanId === '' ? null : record.spanId ?? null;
|
|
198
|
+
if (traceId !== null && (typeof traceId !== 'string' || !HEX_TRACE.test(traceId))) {
|
|
199
|
+
errors.push(issue('invalid_trace_id', 'record.traceId', 'traceId must be 32 hexadecimal characters'));
|
|
200
|
+
}
|
|
201
|
+
if (spanId !== null && (typeof spanId !== 'string' || !HEX_SPAN.test(spanId))) {
|
|
202
|
+
errors.push(issue('invalid_span_id', 'record.spanId', 'spanId must be 16 hexadecimal characters'));
|
|
203
|
+
}
|
|
204
|
+
const messageUuid = attributes['message.uuid'];
|
|
205
|
+
if (messageUuid !== undefined && typeof messageUuid !== 'string') {
|
|
206
|
+
errors.push(issue('invalid_attribute_type', 'attributes.message.uuid', 'message.uuid must be a string'));
|
|
207
|
+
}
|
|
208
|
+
const nativeEventId = (eventName === 'claude_code.user_prompt' || eventName === 'claude_code.assistant_response')
|
|
209
|
+
&& typeof messageUuid === 'string' && messageUuid
|
|
210
|
+
? messageUuid
|
|
211
|
+
: null;
|
|
212
|
+
const validTrace = typeof traceId === 'string' && HEX_TRACE.test(traceId) && traceId !== ZERO_TRACE;
|
|
213
|
+
const validSpan = typeof spanId === 'string' && HEX_SPAN.test(spanId) && spanId !== ZERO_SPAN;
|
|
214
|
+
return {
|
|
215
|
+
native_event_id: nativeEventId,
|
|
216
|
+
trace_id: validTrace ? traceId.toLowerCase() : null,
|
|
217
|
+
span_id: validSpan ? spanId.toLowerCase() : null,
|
|
218
|
+
event_index: null,
|
|
219
|
+
dedup_eligible: nativeEventId !== null,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Decode one already-unmarshalled OTLP LogRecord without adding source identity or persistence policy. */
|
|
224
|
+
export function decodeLogEvidence({ record, resourceAttributes = [], scopeAttributes = [], receivedAt }) {
|
|
225
|
+
const raw = { record, resourceAttributes, scopeAttributes, receivedAt };
|
|
226
|
+
const errors = [];
|
|
227
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
|
228
|
+
return {
|
|
229
|
+
raw, decoder: null, status: 'decode_error', event_name: null, harness: null,
|
|
230
|
+
native_session_id: null, recorded_at: null, recorded_time_unix_nano: null,
|
|
231
|
+
message: null, coverage_reasons: ['parse_error'], identity: null,
|
|
232
|
+
errors: [issue('invalid_record', 'record', 'record must be an object')],
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
if (!Number.isSafeInteger(receivedAt) || receivedAt < 0) {
|
|
236
|
+
errors.push(issue('invalid_received_at', 'receivedAt', 'receivedAt must be a non-negative safe integer millisecond timestamp'));
|
|
237
|
+
}
|
|
238
|
+
const resource = decodeAttributes(resourceAttributes, 'resourceAttributes', errors);
|
|
239
|
+
decodeAttributes(scopeAttributes, 'scopeAttributes', errors);
|
|
240
|
+
const local = decodeAttributes(record.attributes, 'record.attributes', errors);
|
|
241
|
+
const attributes = { ...resource, ...local };
|
|
242
|
+
const topName = record.eventName === undefined ? null : record.eventName;
|
|
243
|
+
if (topName !== null && typeof topName !== 'string') errors.push(issue('invalid_event_name', 'record.eventName', 'eventName must be a string'));
|
|
244
|
+
const attributeName = stringAttribute(attributes, 'event.name', errors);
|
|
245
|
+
const normalizedAttribute = attributeName ? normalizeName(attributeName) : null;
|
|
246
|
+
const attributeDecoder = decoderFor(normalizedAttribute);
|
|
247
|
+
const topIsCallsite = attributeDecoder !== null && DECODER_FACTS[attributeDecoder].callsiteEventName;
|
|
248
|
+
const normalizedTop = typeof topName === 'string' && topName && !topIsCallsite ? normalizeName(topName) : null;
|
|
249
|
+
if (normalizedTop && normalizedAttribute && normalizedTop !== normalizedAttribute) {
|
|
250
|
+
errors.push(issue('event_name_disagreement', 'record.eventName', 'eventName and event.name disagree after normalization'));
|
|
251
|
+
}
|
|
252
|
+
const body = typeof record.body === 'string'
|
|
253
|
+
? record.body
|
|
254
|
+
: record.body === undefined ? undefined : decodeAnyValue(record.body, 'record.body', errors);
|
|
255
|
+
const legacyBodyName = !normalizedTop && !normalizedAttribute && typeof body === 'string' && body ? normalizeName(body) : null;
|
|
256
|
+
const eventName = normalizedTop ?? normalizedAttribute ?? legacyBodyName;
|
|
257
|
+
const decoder = decoderFor(eventName);
|
|
258
|
+
const definition = eventName ? MESSAGE_EVENTS[eventName] : undefined;
|
|
259
|
+
const sessionId = stringAttribute(attributes, decoder ? DECODER_FACTS[decoder].session : 'session.id', errors);
|
|
260
|
+
const reportedUser = ['user.email','user.account_id','user.account_uuid','user.id'].map(key=>stringAttribute(attributes,key,errors)).find(Boolean) ?? null;
|
|
261
|
+
// OTLP: a zero or absent time_unix_nano means the event time is unknown and consumers use
|
|
262
|
+
// observed_time_unix_nano (Codex sends "0" on every record).
|
|
263
|
+
const eventAt = milliseconds(record.timeUnixNano, 'record.timeUnixNano', errors);
|
|
264
|
+
const observedAt = record.observedTimeUnixNano !== undefined ? milliseconds(record.observedTimeUnixNano, 'record.observedTimeUnixNano', errors) : null;
|
|
265
|
+
const recordedAt = eventAt ?? observedAt;
|
|
266
|
+
const identity = identityFacts(record, attributes, eventName, errors);
|
|
267
|
+
const reasons = [];
|
|
268
|
+
let message = null;
|
|
269
|
+
let status = 'activity';
|
|
270
|
+
if (!decoder) reasons.push('unsupported_decoder');
|
|
271
|
+
if (decoder && !sessionId) {
|
|
272
|
+
status = 'unassociated';
|
|
273
|
+
}
|
|
274
|
+
if (definition) {
|
|
275
|
+
const contentValue = attributes[definition.content];
|
|
276
|
+
if (contentValue !== undefined && typeof contentValue !== 'string') {
|
|
277
|
+
errors.push(issue('invalid_attribute_type', `attributes.${definition.content}`, `${definition.content} must be a string`));
|
|
278
|
+
}
|
|
279
|
+
const content = typeof contentValue === 'string' && contentValue.length > 0 ? contentValue : null;
|
|
280
|
+
reasons.push(...coverageForContent(content));
|
|
281
|
+
if (sessionId && content && !REDACTION_MARKERS.has(content)) {
|
|
282
|
+
message = {
|
|
283
|
+
role: definition.role,
|
|
284
|
+
content,
|
|
285
|
+
metadata: { telemetry_decoder: definition.decoder, telemetry_event_name: eventName },
|
|
286
|
+
};
|
|
287
|
+
status = 'decoded';
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// Log records lack a trusted process-incarnation + documented sequence pair. Dedup identity
|
|
291
|
+
// does not establish ordering, so every projection from this decoder remains unsequenced.
|
|
292
|
+
reasons.push('unsequenced');
|
|
293
|
+
if (errors.length > 0) status = 'decode_error';
|
|
294
|
+
return {
|
|
295
|
+
raw,
|
|
296
|
+
decoder,
|
|
297
|
+
status,
|
|
298
|
+
event_name: eventName,
|
|
299
|
+
harness: definition?.harness ?? (decoder ? DECODER_FACTS[decoder].harness : null),
|
|
300
|
+
native_session_id: sessionId,
|
|
301
|
+
reported_user: reportedUser,
|
|
302
|
+
recorded_at: recordedAt,
|
|
303
|
+
recorded_time_unix_nano: eventAt !== null && typeof record.timeUnixNano === 'string' ? record.timeUnixNano : observedAt !== null && typeof record.observedTimeUnixNano === 'string' ? record.observedTimeUnixNano : null,
|
|
304
|
+
message: errors.length === 0 ? message : null,
|
|
305
|
+
coverage_reasons: [...new Set(errors.length ? ['parse_error', ...reasons] : reasons)],
|
|
306
|
+
identity,
|
|
307
|
+
errors,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export { DECODERS };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { RawOtlpEvidence } from "./transport.mjs";
|
|
2
|
+
import type { DecodedLogEvidence } from "./decoders/index.mjs";
|
|
3
|
+
export interface ProjectedOtlpEvidence { teamId: string; sourceId: string; evidenceId: string; proposedSessionId: string; proposedMessageId: string; nativeEventKey: string | null; signal: "logs"|"traces"|"metrics"; raw: RawOtlpEvidence; decoded: DecodedLogEvidence | null }
|
|
4
|
+
export declare function projectOtlpEvidence(evidence: RawOtlpEvidence, context: { teamId: string; sourceId: string; ids: { next(kind: string): string } }): ProjectedOtlpEvidence;
|
|
5
|
+
export declare function projectOtlpBatch(records: RawOtlpEvidence[], context: { teamId: string; sourceId: string; ids: { next(kind: string): string } }): ProjectedOtlpEvidence[];
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { decodeLogEvidence } from "./decoders/index.mjs";
|
|
2
|
+
|
|
3
|
+
function attributes(value) { return value?.attributes ?? []; }
|
|
4
|
+
export function projectOtlpEvidence(evidence, { teamId, sourceId, ids }) {
|
|
5
|
+
const decoded = evidence.signal === "logs" && evidence.evidenceKind === "log_record"
|
|
6
|
+
? decodeLogEvidence({ record: evidence.record, resourceAttributes: attributes(evidence.resource), scopeAttributes: attributes(evidence.scope), receivedAt: evidence.receivedAt })
|
|
7
|
+
: null;
|
|
8
|
+
const nativeEventKey = decoded?.identity?.dedup_eligible
|
|
9
|
+
? `${decoded.harness}:${decoded.native_session_id ?? ""}:${decoded.identity.native_event_id}` : null;
|
|
10
|
+
return {
|
|
11
|
+
teamId, sourceId, evidenceId: ids.next("evidence"), proposedSessionId: ids.next("session"),
|
|
12
|
+
proposedMessageId: ids.next("message"), nativeEventKey, signal: evidence.signal,
|
|
13
|
+
raw: evidence, decoded,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function projectOtlpBatch(records, context) { return records.map((record) => projectOtlpEvidence(record, context)); }
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { RawOtlpEvidence, OtlpSignal } from "./transport.mjs";
|
|
2
|
+
export interface OtlpHttpResponse { status: number; headers: Record<string, string>; body: Uint8Array }
|
|
3
|
+
export declare function createOtlpHttpReceiver<T>(options: { persist(input: { records: RawOtlpEvidence[]; rejectedCount: number; sourceAuthorization: T }): Promise<void>; maxUncompressedBytes?: number }): (request: { signal: OtlpSignal; headers: Headers | Record<string, string | string[] | undefined>; body: Uint8Array | string | AsyncIterable<Uint8Array | string>; sourceAuthorization: T; receivedAt?: number }) => Promise<OtlpHttpResponse>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { decodeContentEncoding, decodeOtlpRequest, encodeOtlpError, encodeOtlpResponse, MAX_UNCOMPRESSED_BODY_BYTES, readBoundedBody } from "./transport.mjs";
|
|
2
|
+
|
|
3
|
+
function header(headers, name) {
|
|
4
|
+
if (!headers) return undefined;
|
|
5
|
+
if (typeof headers.get === "function") return headers.get(name) ?? undefined;
|
|
6
|
+
const key = Object.keys(headers).find((candidate) => candidate.toLowerCase() === name);
|
|
7
|
+
const value = key ? headers[key] : undefined;
|
|
8
|
+
return Array.isArray(value) ? value[0] : value;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createOtlpHttpReceiver({ persist, maxUncompressedBytes = MAX_UNCOMPRESSED_BODY_BYTES }) {
|
|
12
|
+
if (typeof persist !== "function") throw new TypeError("persist callback is required");
|
|
13
|
+
return async function handle({ signal, headers, body, sourceAuthorization, receivedAt = Date.now() }) {
|
|
14
|
+
const contentType = String(header(headers, "content-type") ?? "").split(";", 1)[0].trim().toLowerCase();
|
|
15
|
+
const representation = contentType === "application/json" ? "json" : contentType === "application/x-protobuf" ? "protobuf" : null;
|
|
16
|
+
if (!representation) return response(415, "application/json", await encodeOtlpError({ representation: "json", code: 12, message: "unsupported OTLP content type" }));
|
|
17
|
+
let decoded;
|
|
18
|
+
try {
|
|
19
|
+
const compressed = await readBoundedBody(body, maxUncompressedBytes);
|
|
20
|
+
const bytes = decodeContentEncoding(compressed, header(headers, "content-encoding"), maxUncompressedBytes);
|
|
21
|
+
decoded = await decodeOtlpRequest({ signal, representation, bytes, receivedAt });
|
|
22
|
+
} catch (error) {
|
|
23
|
+
const status = error?.code === "BODY_TOO_LARGE" ? 413 : error?.code === "UNSUPPORTED_ENCODING" ? 415 : 400;
|
|
24
|
+
const code = status === 413 ? 8 : status === 415 ? 12 : 3;
|
|
25
|
+
const message = status === 413 ? "OTLP request body exceeds the configured limit" : status === 415 ? "unsupported OTLP content encoding" : "malformed OTLP request";
|
|
26
|
+
return response(status, contentType, await encodeOtlpError({ representation, code, message }));
|
|
27
|
+
}
|
|
28
|
+
// This callback is the commit boundary. A response is constructed only after it resolves.
|
|
29
|
+
if (decoded.records.length || decoded.rejected.length) await persist({ records: decoded.records, rejectedCount: decoded.rejected.length, sourceAuthorization });
|
|
30
|
+
const payload = await encodeOtlpResponse({ signal, representation, rejectedCount: decoded.rejected.length,
|
|
31
|
+
errorMessage: decoded.rejected.length ? "one or more records were rejected" : "" });
|
|
32
|
+
return response(200, contentType, payload);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function response(status, contentType, body) { return { status, headers: { "content-type": contentType }, body }; }
|