diff --git a/package-lock.json b/package-lock.json index e7f9bcc..0a55fcc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@graphql-codegen/introspection": "^5.0.0", "@graphql-codegen/typescript": "^5.0.2", "@graphql-codegen/typescript-operations": "^5.0.2", + "@supabase/supabase-js": "^2.47.0", "@types/jest": "^29.5.11", "@types/node": "^20.10.6", "@typescript-eslint/eslint-plugin": "^6.17.0", @@ -6065,6 +6066,92 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@supabase/auth-js": { + "version": "2.98.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.98.0.tgz", + "integrity": "sha512-GBH361T0peHU91AQNzOlIrjUZw9TZbB9YDRiyFgk/3Kvr3/Z1NWUZ2athWTfHhwNNi8IrW00foyFxQD9IO/Trg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.98.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.98.0.tgz", + "integrity": "sha512-N/xEyiNU5Org+d+PNCpv+TWniAXRzxIURxDYsS/m2I/sfAB/HcM9aM2Dmf5edj5oWb9GxID1OBaZ8NMmPXL+Lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.98.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.98.0.tgz", + "integrity": "sha512-v6e9WeZuJijzUut8HyXu6gMqWFepIbaeaMIm1uKzei4yLg9bC9OtEW9O14LE/9ezqNbSAnSLO5GtOLFdm7Bpkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.98.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.98.0.tgz", + "integrity": "sha512-rOWt28uGyFipWOSd+n0WVMr9kUXiWaa7J4hvyLCIHjRFqWm1z9CaaKAoYyfYMC1Exn3WT8WePCgiVhlAtWC2yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.98.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.98.0.tgz", + "integrity": "sha512-tzr2mG+v7ILSAZSfZMSL9OPyIH4z1ikgQ8EcQTKfMRz4EwmlFt3UnJaGzSOxyvF5b+fc9So7qdSUWTqGgeLokQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.98.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.98.0.tgz", + "integrity": "sha512-Ohc97CtInLwZyiSASz7tT9/Abm/vqnIbO9REp+PivVUII8UZsuI3bngRQnYgJdFoOIwvaEII1fX1qy8x0CyNiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.98.0", + "@supabase/functions-js": "2.98.0", + "@supabase/postgrest-js": "2.98.0", + "@supabase/realtime-js": "2.98.0", + "@supabase/storage-js": "2.98.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@theguild/federation-composition": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@theguild/federation-composition/-/federation-composition-0.20.2.tgz", @@ -6204,6 +6291,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/phoenix": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", + "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/retry": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", @@ -9580,6 +9674,16 @@ "node": ">=10.17.0" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -15901,9 +16005,9 @@ } }, "node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", "peer": true, "engines": { diff --git a/package.json b/package.json index 64c5b19..04d0929 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "viem": "^2.37.5" }, "devDependencies": { + "@supabase/supabase-js": "^2.47.0", "@graphql-codegen/cli": "^6.0.1", "@graphql-codegen/introspection": "^5.0.0", "@graphql-codegen/typescript": "^5.0.2", diff --git a/src/core/agent.ts b/src/core/agent.ts index 623c13d..312571f 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -427,6 +427,7 @@ export class Agent { signature?: string | Uint8Array; } ): Promise | undefined> { + const start = Date.now(); if (!this.registrationFile.agentId) { throw new Error( 'Agent must be registered before setting agentWallet on-chain. ' + @@ -454,6 +455,14 @@ export class Agent { this.registrationFile.walletAddress = newWallet; this.registrationFile.walletChainId = chainId; this.registrationFile.updatedAt = Math.floor(Date.now() / 1000); + const payload = this.sdk.getAgentSnapshotForTelemetry(chainId, this.registrationFile.agentId!, this.registrationFile); + this.sdk.emitTelemetryEvent({ + eventType: 'agent.wallet.set', + success: true, + durationMs: Date.now() - start, + timestamp: Date.now(), + payload, + }); return undefined; } } catch { @@ -677,10 +686,17 @@ export class Agent { }); return new TransactionHandle(txHash as Hex, this.sdk.chainClient, async () => { - // Update local registration file only after confirmation to avoid lying on reverts. this.registrationFile.walletAddress = newWallet; this.registrationFile.walletChainId = chainId; this.registrationFile.updatedAt = Math.floor(Date.now() / 1000); + const payload = this.sdk.getAgentSnapshotForTelemetry(chainId, this.registrationFile.agentId!, this.registrationFile); + this.sdk.emitTelemetryEvent({ + eventType: 'agent.wallet.set', + success: true, + durationMs: Date.now() - start, + timestamp: Date.now(), + payload, + }); return this.registrationFile; }); } @@ -692,6 +708,7 @@ export class Agent { * Returns txHash (or "" if it was already unset). */ async unsetWallet(): Promise | undefined> { + const start = Date.now(); if (!this.registrationFile.agentId) { throw new Error( 'Agent must be registered before unsetting agentWallet on-chain. ' + @@ -706,13 +723,21 @@ export class Agent { const { tokenId } = parseAgentId(this.registrationFile.agentId); const identityRegistryAddress = this.sdk.identityRegistryAddress(); - // Optional short-circuit if already unset (best-effort). try { const currentWallet = await this.getWallet(); if (!currentWallet) { this.registrationFile.walletAddress = undefined; this.registrationFile.walletChainId = undefined; this.registrationFile.updatedAt = Math.floor(Date.now() / 1000); + const chainId = await this.sdk.chainId(); + const payload = this.sdk.getAgentSnapshotForTelemetry(chainId, this.registrationFile.agentId!, this.registrationFile); + this.sdk.emitTelemetryEvent({ + eventType: 'agent.wallet.unset', + success: true, + durationMs: Date.now() - start, + timestamp: Date.now(), + payload, + }); return undefined; } } catch { @@ -730,6 +755,15 @@ export class Agent { this.registrationFile.walletAddress = undefined; this.registrationFile.walletChainId = undefined; this.registrationFile.updatedAt = Math.floor(Date.now() / 1000); + const chainId = await this.sdk.chainId(); + const payload = this.sdk.getAgentSnapshotForTelemetry(chainId, this.registrationFile.agentId!, this.registrationFile); + this.sdk.emitTelemetryEvent({ + eventType: 'agent.wallet.unset', + success: true, + durationMs: Date.now() - start, + timestamp: Date.now(), + payload, + }); return this.registrationFile; }); } @@ -837,7 +871,7 @@ export class Agent { * Register agent on-chain with IPFS flow */ async registerIPFS(): Promise> { - // Validate basic info + const start = Date.now(); if (!this.registrationFile.name || !this.registrationFile.description) { throw new Error('Agent must have name and description before registration'); } @@ -868,22 +902,27 @@ export class Agent { }); return new TransactionHandle(txHash as Hex, this.sdk.chainClient, async () => { - // Best-effort metadata updates (may involve additional txs) if (this._dirtyMetadata.size > 0) { try { await this._updateMetadataOnChain(); } catch { - // Preserve previous behavior: ignore failures/timeouts and continue. + // ignore } } - - // Clear dirty flags this._lastRegisteredWallet = this.walletAddress; this._lastRegisteredEns = this.ensEndpoint; this._dirtyMetadata.clear(); - this.registrationFile.agentURI = `ipfs://${ipfsCid}`; this.registrationFile.updatedAt = Math.floor(Date.now() / 1000); + const payload = this.sdk.getAgentSnapshotForTelemetry(chainId, this.registrationFile.agentId!, this.registrationFile) as Record; + payload.fieldsChanged = ['agentURI']; + this.sdk.emitTelemetryEvent({ + eventType: 'agent.updated', + success: true, + durationMs: Date.now() - start, + timestamp: Date.now(), + payload, + }); return this.registrationFile; }); } else { @@ -932,6 +971,15 @@ export class Agent { this.registrationFile.agentURI = `ipfs://${ipfsCid}`; this.registrationFile.updatedAt = Math.floor(Date.now() / 1000); + const payload = this.sdk.getAgentSnapshotForTelemetry(chainId, this.registrationFile.agentId!, this.registrationFile) as Record; + payload.registrationType = 'ipfs'; + this.sdk.emitTelemetryEvent({ + eventType: 'agent.registered', + success: true, + durationMs: Date.now() - start, + timestamp: Date.now(), + payload, + }); return this.registrationFile; }); } @@ -1001,6 +1049,7 @@ export class Agent { async transfer( newOwner: Address ): Promise> { + const start = Date.now(); if (!this.registrationFile.agentId) { throw new Error('Agent must be registered before transfer'); } @@ -1035,10 +1084,18 @@ export class Agent { args: [currentOwner, checksumAddress, BigInt(tokenId)], }); return new TransactionHandle(txHash as Hex, this.sdk.chainClient, async () => { - // transfer resets agentWallet on-chain; reflect that locally after confirmation this.registrationFile.walletAddress = undefined; this.registrationFile.walletChainId = undefined; this.registrationFile.updatedAt = Math.floor(Date.now() / 1000); + const chainId = await this.sdk.chainId(); + const payload = this.sdk.getAgentSnapshotForTelemetry(chainId, this.registrationFile.agentId!, this.registrationFile); + this.sdk.emitTelemetryEvent({ + eventType: 'agent.transferred', + success: true, + durationMs: Date.now() - start, + timestamp: Date.now(), + payload, + }); return { txHash, from: currentOwner, diff --git a/src/core/sdk.ts b/src/core/sdk.ts index ab7a772..2b7b870 100644 --- a/src/core/sdk.ts +++ b/src/core/sdk.ts @@ -32,6 +32,7 @@ import { IDENTITY_REGISTRY_ABI, REPUTATION_REGISTRY_ABI, } from './contracts.js'; +import { TelemetryClient, categorizeError, type TelemetryEvent } from './telemetry.js'; export interface SDKConfig { chainId: ChainId; @@ -64,6 +65,14 @@ export interface SDKConfig { // Subgraph configuration subgraphUrl?: string; subgraphOverrides?: Record; + /** + * Optional API key for telemetry. When set, SDK methods emit events to the ingest endpoint. + */ + apiKey?: string; + /** + * Optional telemetry ingest URL. Defaults to production Supabase edge function. + */ + telemetryEndpoint?: string; /** * Max decoded bytes for ERC-8004 JSON base64 data URIs (on-chain registration files). * Default: 256 KiB. @@ -84,6 +93,7 @@ export class SDK { private readonly _chainId: ChainId; private readonly _subgraphUrls: Record = {}; private readonly _hasSignerConfig: boolean; + private readonly _telemetry?: TelemetryClient; private readonly _registrationDataUriMaxBytes: number; constructor(config: SDKConfig) { @@ -146,6 +156,20 @@ export class SDK { (chainId) => this.getSubgraphClient(chainId), this._chainId ); + + if (config.apiKey) { + this._telemetry = new TelemetryClient({ + apiKey: config.apiKey, + endpoint: config.telemetryEndpoint, + }); + } + } + + /** + * Emit a single telemetry event (for use by Agent and SDK methods). No-op if telemetry is disabled. + */ + emitTelemetryEvent(event: TelemetryEvent): void { + this._telemetry?.emit([event]); } /** @@ -280,41 +304,54 @@ export class SDK { * Load an existing agent (hydrates from registration file if registered) */ async loadAgent(agentId: AgentId): Promise { - // Parse agent ID - const { chainId, tokenId } = parseAgentId(agentId); - - const currentChainId = await this.chainId(); - if (chainId !== currentChainId) { - throw new Error(`Agent ${agentId} is not on current chain ${currentChainId}`); - } - - // Get agent URI from contract - let agentURI: string; + const start = Date.now(); try { - agentURI = await this._chainClient.readContract({ - address: this.identityRegistryAddress(), - abi: IDENTITY_REGISTRY_ABI, - functionName: 'tokenURI', - args: [BigInt(tokenId)], + const { chainId, tokenId } = parseAgentId(agentId); + const currentChainId = await this.chainId(); + if (chainId !== currentChainId) { + throw new Error(`Agent ${agentId} is not on current chain ${currentChainId}`); + } + let agentURI: string; + try { + agentURI = await this._chainClient.readContract({ + address: this.identityRegistryAddress(), + abi: IDENTITY_REGISTRY_ABI, + functionName: 'tokenURI', + args: [BigInt(tokenId)], + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to load agent ${agentId}: ${errorMessage}`); + } + let registrationFile: RegistrationFile; + if (!agentURI || agentURI === '') { + registrationFile = this._createEmptyRegistrationFile(); + } else { + registrationFile = await this._loadRegistrationFile(agentURI); + } + registrationFile.agentId = agentId; + registrationFile.agentURI = agentURI || undefined; + const agent = new Agent(this, registrationFile); + this.emitTelemetryEvent({ + eventType: 'agent.loaded', + success: true, + durationMs: Date.now() - start, + timestamp: Date.now(), + payload: this.getAgentSnapshotForTelemetry(chainId, agentId, registrationFile), }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to load agent ${agentId}: ${errorMessage}`); - } - - // Load registration file - handle empty URI (agent registered without URI yet) - let registrationFile: RegistrationFile; - if (!agentURI || agentURI === '') { - // Agent registered but no URI set yet - create empty registration file - registrationFile = this._createEmptyRegistrationFile(); - } else { - registrationFile = await this._loadRegistrationFile(agentURI); + return agent; + } catch (err) { + const { chainId } = parseAgentId(agentId); + this.emitTelemetryEvent({ + eventType: 'agent.loaded', + success: false, + errorType: categorizeError(err), + durationMs: Date.now() - start, + timestamp: Date.now(), + payload: { chainId, agentId }, + }); + throw err; } - - registrationFile.agentId = agentId; - registrationFile.agentURI = agentURI || undefined; - - return new Agent(this, registrationFile); } /** @@ -322,34 +359,54 @@ export class SDK { * Supports both default chain and explicit chain specification via chainId:tokenId format */ async getAgent(agentId: AgentId): Promise { - // Parse agentId to extract chainId if present - // If no colon, assume it's just tokenId on default chain + const start = Date.now(); let parsedChainId: number; let formattedAgentId: string; - if (agentId.includes(':')) { const parsed = parseAgentId(agentId); parsedChainId = parsed.chainId; - formattedAgentId = agentId; // Already in correct format + formattedAgentId = agentId; } else { - // No colon - use default chain parsedChainId = this._chainId; formattedAgentId = formatAgentId(this._chainId, parseInt(agentId, 10)); } - - // Determine which chain to query const targetChainId = parsedChainId !== this._chainId ? parsedChainId : undefined; - - // Get subgraph client for the target chain (or use default) const subgraphClient = targetChainId ? this.getSubgraphClient(targetChainId) : this._subgraphClient; - if (!subgraphClient) { - throw new Error(`Subgraph client required for getAgent on chain ${targetChainId || this._chainId}`); + const err = new Error(`Subgraph client required for getAgent on chain ${targetChainId ?? this._chainId}`); + this.emitTelemetryEvent({ + eventType: 'agent.fetched', + success: false, + errorType: categorizeError(err), + durationMs: Date.now() - start, + timestamp: Date.now(), + payload: { chainId: parsedChainId, agentId: formattedAgentId, found: false }, + }); + throw err; + } + try { + const result = await subgraphClient.getAgentById(formattedAgentId); + this.emitTelemetryEvent({ + eventType: 'agent.fetched', + success: true, + durationMs: Date.now() - start, + timestamp: Date.now(), + payload: { chainId: parsedChainId, agentId: formattedAgentId, found: result != null }, + }); + return result; + } catch (err) { + this.emitTelemetryEvent({ + eventType: 'agent.fetched', + success: false, + errorType: categorizeError(err), + durationMs: Date.now() - start, + timestamp: Date.now(), + payload: { chainId: parsedChainId, agentId: formattedAgentId, found: false }, + }); + throw err; } - - return subgraphClient.getAgentById(formattedAgentId); } /** @@ -360,7 +417,89 @@ export class SDK { filters: SearchFilters = {}, options: SearchOptions = {} ): Promise { - return this._indexer.searchAgents(filters, options); + const start = Date.now(); + try { + const results = await this._indexer.searchAgents(filters, options); + const payload: Record = this._telemetrySearchPayload(filters, options, results); + payload.results = results.slice(0, 100).map((a) => a.agentId); + this.emitTelemetryEvent({ + eventType: 'search.query', + success: true, + durationMs: Date.now() - start, + timestamp: Date.now(), + payload, + }); + return results; + } catch (err) { + const payload: Record = this._telemetrySearchPayload(filters, options, []); + payload.results = []; + this.emitTelemetryEvent({ + eventType: 'search.query', + success: false, + errorType: categorizeError(err), + durationMs: Date.now() - start, + timestamp: Date.now(), + payload, + }); + throw err; + } + } + + private _telemetrySearchPayload( + filters: SearchFilters, + options: SearchOptions, + _results: AgentSummary[] + ): Record { + const toUnix = (v: Date | string | number | undefined): number | undefined => { + if (v === undefined) return undefined; + if (typeof v === 'number') return v < 1e12 ? v : Math.floor(v / 1000); + const d = typeof v === 'string' ? new Date(v) : v; + return Math.floor(d.getTime() / 1000); + }; + const p: Record = {}; + if (filters.chains !== undefined) p.chains = filters.chains; + if (filters.agentIds?.length) p.agentIds = filters.agentIds; + if (filters.name !== undefined) p.name = filters.name; + if (filters.description !== undefined) p.description = filters.description; + if (filters.keyword !== undefined) p.keyword = filters.keyword; + if (filters.owners?.length) p.owners = filters.owners; + if (filters.operators?.length) p.operators = filters.operators; + if (filters.hasRegistrationFile !== undefined) p.hasRegistrationFile = filters.hasRegistrationFile; + if (filters.hasWeb !== undefined) p.hasWeb = filters.hasWeb; + if (filters.hasMCP !== undefined) p.hasMCP = filters.hasMCP; + if (filters.hasA2A !== undefined) p.hasA2A = filters.hasA2A; + if (filters.hasOASF !== undefined) p.hasOASF = filters.hasOASF; + if (filters.hasEndpoints !== undefined) p.hasEndpoints = filters.hasEndpoints; + if (filters.webContains !== undefined) p.webContains = filters.webContains; + if (filters.mcpContains !== undefined) p.mcpContains = filters.mcpContains; + if (filters.a2aContains !== undefined) p.a2aContains = filters.a2aContains; + if (filters.ensContains !== undefined) p.ensContains = filters.ensContains; + if (filters.didContains !== undefined) p.didContains = filters.didContains; + if (filters.walletAddress !== undefined) p.agentWallet = { address: filters.walletAddress }; + if (filters.supportedTrust?.length) p.supportedTrust = filters.supportedTrust; + if (filters.a2aSkills?.length) p.a2aSkills = filters.a2aSkills; + if (filters.mcpTools?.length) p.mcpTools = filters.mcpTools; + if (filters.mcpPrompts?.length) p.mcpPrompts = filters.mcpPrompts; + if (filters.mcpResources?.length) p.mcpResources = filters.mcpResources; + if (filters.oasfSkills?.length) p.oasfSkills = filters.oasfSkills; + if (filters.oasfDomains?.length) p.oasfDomains = filters.oasfDomains; + if (filters.active !== undefined) p.active = filters.active; + if (filters.x402support !== undefined) p.x402support = filters.x402support; + const rFrom = toUnix(filters.registeredAtFrom); + const rTo = toUnix(filters.registeredAtTo); + const uFrom = toUnix(filters.updatedAtFrom); + const uTo = toUnix(filters.updatedAtTo); + if (rFrom !== undefined) p.registeredAtFrom = rFrom; + if (rTo !== undefined) p.registeredAtTo = rTo; + if (uFrom !== undefined) p.updatedAtFrom = uFrom; + if (uTo !== undefined) p.updatedAtTo = uTo; + if (filters.hasMetadataKey !== undefined) p.hasMetadataKey = filters.hasMetadataKey; + if (filters.metadataValue !== undefined) p.metadataValue = filters.metadataValue; + if (filters.feedback !== undefined) p.feedback = filters.feedback; + if (options.sort?.length) p.sort = options.sort; + if (options.semanticMinScore !== undefined) p.semanticMinScore = options.semanticMinScore; + if (options.semanticTopK !== undefined) p.semanticTopK = options.semanticTopK; + return p; } /** @@ -423,18 +562,80 @@ export class SDK { endpoint?: string, feedbackFile?: FeedbackFileInput ): Promise> { - // Update feedback manager with registries + const start = Date.now(); this._feedbackManager.setReputationRegistryAddress(this.reputationRegistryAddress()); this._feedbackManager.setIdentityRegistryAddress(this.identityRegistryAddress()); - - return this._feedbackManager.giveFeedback(agentId, value, tag1, tag2, endpoint, feedbackFile); + const valueNum = typeof value === 'string' ? parseInt(value, 10) : value; + try { + const result = await this._feedbackManager.giveFeedback(agentId, value, tag1, tag2, endpoint, feedbackFile); + this.emitTelemetryEvent({ + eventType: 'feedback.given', + success: true, + durationMs: Date.now() - start, + timestamp: Date.now(), + payload: { + chainId: this._chainId, + agentId, + value: valueNum, + tag1, + tag2, + hasEndpoint: endpoint != null && endpoint !== '', + endpoint: endpoint ?? undefined, + hasOffchainFile: feedbackFile != null, + hasText: feedbackFile?.text != null, + hasContext: feedbackFile?.context != null, + hasProofOfPayment: feedbackFile?.proofOfPayment != null, + // Spec-aligned feedback file fields (1.6+) + mcpTool: feedbackFile?.mcpTool, + mcpPrompt: feedbackFile?.mcpPrompt, + mcpResource: feedbackFile?.mcpResource, + a2aSkills: feedbackFile?.a2aSkills, + a2aContextId: feedbackFile?.a2aContextId, + a2aTaskId: feedbackFile?.a2aTaskId, + oasfSkills: feedbackFile?.oasfSkills, + oasfDomains: feedbackFile?.oasfDomains, + }, + }); + return result; + } catch (err) { + this.emitTelemetryEvent({ + eventType: 'feedback.given', + success: false, + errorType: categorizeError(err), + durationMs: Date.now() - start, + timestamp: Date.now(), + payload: { chainId: this._chainId, agentId, value: valueNum, tag1, tag2 }, + }); + throw err; + } } /** * Read feedback */ async getFeedback(agentId: AgentId, clientAddress: Address, feedbackIndex: number): Promise { - return this._feedbackManager.getFeedback(agentId, clientAddress, feedbackIndex); + const start = Date.now(); + try { + const result = await this._feedbackManager.getFeedback(agentId, clientAddress, feedbackIndex); + this.emitTelemetryEvent({ + eventType: 'feedback.fetched', + success: true, + durationMs: Date.now() - start, + timestamp: Date.now(), + payload: { chainId: this._chainId, agentId, feedbackIndex, found: true }, + }); + return result; + } catch (err) { + this.emitTelemetryEvent({ + eventType: 'feedback.fetched', + success: false, + errorType: categorizeError(err), + durationMs: Date.now() - start, + timestamp: Date.now(), + payload: { chainId: this._chainId, agentId, feedbackIndex, found: false }, + }); + throw err; + } } /** @@ -481,7 +682,44 @@ export class SDK { minValue: options.minValue, maxValue: options.maxValue, }; - return this._feedbackManager.searchFeedback(params); + const start = Date.now(); + try { + const result = await this._feedbackManager.searchFeedback(params); + const singleAgentId = agents?.length === 1 ? agents[0] : filters.agentId; + this.emitTelemetryEvent({ + eventType: 'feedback.searched', + success: true, + durationMs: Date.now() - start, + timestamp: Date.now(), + payload: { + chainId: this._chainId, + agentId: singleAgentId, + agentCount: agents?.length, + tags: filters.tags, + reviewers: filters.reviewers, + capabilities: filters.capabilities, + skills: filters.skills, + tasks: filters.tasks, + names: filters.names, + includeRevoked: filters.includeRevoked, + minValue: options.minValue, + maxValue: options.maxValue, + resultCount: result.length, + isZeroResults: result.length === 0, + }, + }); + return result; + } catch (err) { + this.emitTelemetryEvent({ + eventType: 'feedback.searched', + success: false, + errorType: categorizeError(err), + durationMs: Date.now() - start, + timestamp: Date.now(), + payload: { chainId: this._chainId, agentId: filters.agentId, agentCount: agents?.length }, + }); + throw err; + } } /** @@ -493,20 +731,70 @@ export class SDK { feedbackIndex: number, response: { uri: URI; hash: string } ): Promise> { - // Update feedback manager with registries + const start = Date.now(); this._feedbackManager.setReputationRegistryAddress(this.reputationRegistryAddress()); - - return this._feedbackManager.appendResponse(agentId, clientAddress, feedbackIndex, response.uri, response.hash); + try { + const result = await this._feedbackManager.appendResponse( + agentId, + clientAddress, + feedbackIndex, + response.uri, + response.hash + ); + this.emitTelemetryEvent({ + eventType: 'feedback.response.appended', + success: true, + durationMs: Date.now() - start, + timestamp: Date.now(), + payload: { + chainId: this._chainId, + agentId, + clientAddress, + feedbackIndex, + responseUri: response.uri, + }, + }); + return result; + } catch (err) { + this.emitTelemetryEvent({ + eventType: 'feedback.response.appended', + success: false, + errorType: categorizeError(err), + durationMs: Date.now() - start, + timestamp: Date.now(), + payload: { chainId: this._chainId, agentId, clientAddress, feedbackIndex }, + }); + throw err; + } } /** * Revoke feedback */ async revokeFeedback(agentId: AgentId, feedbackIndex: number): Promise> { - // Update feedback manager with registries + const start = Date.now(); this._feedbackManager.setReputationRegistryAddress(this.reputationRegistryAddress()); - - return this._feedbackManager.revokeFeedback(agentId, feedbackIndex); + try { + const result = await this._feedbackManager.revokeFeedback(agentId, feedbackIndex); + this.emitTelemetryEvent({ + eventType: 'feedback.revoked', + success: true, + durationMs: Date.now() - start, + timestamp: Date.now(), + payload: { chainId: this._chainId, agentId, feedbackIndex }, + }); + return result; + } catch (err) { + this.emitTelemetryEvent({ + eventType: 'feedback.revoked', + success: false, + errorType: categorizeError(err), + durationMs: Date.now() - start, + timestamp: Date.now(), + payload: { chainId: this._chainId, agentId, feedbackIndex }, + }); + throw err; + } } /** @@ -517,10 +805,36 @@ export class SDK { tag1?: string, tag2?: string ): Promise<{ count: number; averageValue: number }> { - // Update feedback manager with registries + const start = Date.now(); this._feedbackManager.setReputationRegistryAddress(this.reputationRegistryAddress()); - - return this._feedbackManager.getReputationSummary(agentId, tag1, tag2); + try { + const result = await this._feedbackManager.getReputationSummary(agentId, tag1, tag2); + this.emitTelemetryEvent({ + eventType: 'reputation.summary.fetched', + success: true, + durationMs: Date.now() - start, + timestamp: Date.now(), + payload: { + chainId: this._chainId, + agentId, + tag1, + tag2, + count: result.count, + averageValue: result.averageValue, + }, + }); + return result; + } catch (err) { + this.emitTelemetryEvent({ + eventType: 'reputation.summary.fetched', + success: false, + errorType: categorizeError(err), + durationMs: Date.now() - start, + timestamp: Date.now(), + payload: { chainId: this._chainId, agentId, tag1, tag2 }, + }); + throw err; + } } /** @@ -541,6 +855,36 @@ export class SDK { }; } + /** Build v2 agent snapshot payload for telemetry (chainId, agentId, name, description, endpoints, etc.). Public for use by Agent. */ + getAgentSnapshotForTelemetry(chainId: number, agentId: AgentId, reg: RegistrationFile): Record { + const metadata = + reg.metadata && typeof reg.metadata === 'object' && !Array.isArray(reg.metadata) + ? Object.entries(reg.metadata).map(([key, value]) => ({ key, value: String(value) })) + : []; + const payload: Record = { + chainId, + agentId, + name: reg.name, + description: reg.description, + endpoints: reg.endpoints, + trustModels: reg.trustModels, + owners: reg.owners, + operators: reg.operators, + active: reg.active, + x402support: reg.x402support, + metadata, + updatedAt: reg.updatedAt, + }; + if (reg.image !== undefined) payload.image = reg.image; + if (reg.walletAddress !== undefined || reg.walletChainId !== undefined) { + payload.agentWallet = { + ...(reg.walletAddress !== undefined && { address: reg.walletAddress }), + ...(reg.walletChainId !== undefined && { chainId: reg.walletChainId }), + }; + } + return payload; + } + /** * Private helper methods */ diff --git a/src/core/telemetry.ts b/src/core/telemetry.ts new file mode 100644 index 0000000..b7e4186 --- /dev/null +++ b/src/core/telemetry.ts @@ -0,0 +1,77 @@ +/** + * Telemetry client for SDK events (Telemetry-Events-Specs-v2). + * Fire-and-forget; never blocks or throws. + */ + +export const DEFAULT_TELEMETRY_ENDPOINT = + 'https://pepzouxscqxcejwjcbro.supabase.co/functions/v1/ingest-telemetry'; + +export type TelemetryErrorType = + | 'NETWORK_ERROR' + | 'CONTRACT_ERROR' + | 'VALIDATION_ERROR' + | 'TIMEOUT' + | 'NOT_FOUND' + | 'UNAUTHORIZED' + | 'RATE_LIMITED' + | 'IPFS_ERROR' + | 'SUBGRAPH_ERROR' + | 'UNKNOWN'; + +export interface TelemetryEvent { + eventType: string; + success: boolean; + durationMs: number; + timestamp: number; + payload?: Record; + errorType?: TelemetryErrorType; +} + +export function categorizeError(error: unknown): TelemetryErrorType { + if (error == null) return 'UNKNOWN'; + const msg = error instanceof Error ? error.message : String(error); + const code = error && typeof error === 'object' && 'code' in error ? String((error as { code: unknown }).code) : ''; + if (code === 'NETWORK_ERROR' || /fetch|network|econnrefused|enotfound/i.test(msg)) return 'NETWORK_ERROR'; + if (/CALL_EXCEPTION|contract|revert|execution reverted/i.test(code) || /revert|contract/i.test(msg)) return 'CONTRACT_ERROR'; + if (/validation|invalid|bad request/i.test(msg) || /VALIDATION/i.test(code)) return 'VALIDATION_ERROR'; + if (/timeout|timed out|ETIMEDOUT/i.test(msg)) return 'TIMEOUT'; + if (/not found|404|NOT_FOUND/i.test(msg)) return 'NOT_FOUND'; + if (/unauthorized|403|permission|UNAUTHORIZED/i.test(msg)) return 'UNAUTHORIZED'; + if (/rate limit|429|RATE_LIMITED/i.test(msg)) return 'RATE_LIMITED'; + if (/ipfs|IPFS_ERROR|pinata|pin\.fs/i.test(msg)) return 'IPFS_ERROR'; + if (/subgraph|graphql|SUBGRAPH_ERROR/i.test(msg)) return 'SUBGRAPH_ERROR'; + return 'UNKNOWN'; +} + +export interface TelemetryConfig { + apiKey: string; + endpoint?: string; +} + +export class TelemetryClient { + private readonly apiKey: string; + private readonly endpoint: string; + + constructor(config: TelemetryConfig) { + this.apiKey = config.apiKey; + this.endpoint = config.endpoint ?? DEFAULT_TELEMETRY_ENDPOINT; + } + + /** + * Emit events (fire-and-forget). Never throws; failures are ignored. + */ + emit(events: TelemetryEvent[]): void { + if (events.length === 0) return; + const body = JSON.stringify({ events }); + fetch(this.endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}`, + }, + body, + }).catch(() => { + // Silently ignore telemetry failures + }); + } +} diff --git a/tests/config.ts b/tests/config.ts index 380ec9a..2470f8b 100644 --- a/tests/config.ts +++ b/tests/config.ts @@ -35,6 +35,14 @@ export const AGENT_ID = process.env.AGENT_ID || '11155111:374'; // Load from environment variable for security export const CLIENT_PRIVATE_KEY = process.env.CLIENT_PRIVATE_KEY || ''; +// Telemetry / API key (for local ingest and SDK integration tests) +export const AGENT0_API_KEY = process.env.AGENT0_API_KEY || ''; +export const AGENT0_TELEMETRY_ENDPOINT = process.env.AGENT0_TELEMETRY_ENDPOINT || ''; + +// Supabase (for telemetry DB assertions in integration tests; service role bypasses RLS) +export const SUPABASE_URL = process.env.SUPABASE_URL || ''; +export const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || ''; + /** * Print current configuration (hiding sensitive values). */ @@ -46,6 +54,10 @@ export function printConfig(): void { console.log(` PINATA_JWT: ${PINATA_JWT ? '***' : 'NOT SET'}`); console.log(` SUBGRAPH_URL: ${SUBGRAPH_URL.substring(0, 50)}...`); console.log(` AGENT_ID: ${AGENT_ID}`); - console.log(); + console.log(` AGENT0_API_KEY: ${AGENT0_API_KEY ? '***' : 'NOT SET'}`); + console.log(` AGENT0_TELEMETRY_ENDPOINT: ${AGENT0_TELEMETRY_ENDPOINT || '(default)'}`); + console.log(` SUPABASE_URL: ${SUPABASE_URL ? '***' : 'NOT SET'}`); + console.log(` SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY ? '***' : 'NOT SET'}`); + console.log(''); } diff --git a/tests/telemetry-sdk.test.ts b/tests/telemetry-sdk.test.ts new file mode 100644 index 0000000..526d45e --- /dev/null +++ b/tests/telemetry-sdk.test.ts @@ -0,0 +1,199 @@ +/** + * Integration tests: SDK telemetry (Telemetry-Events-Specs-v2.md). + * + * Set in .env: AGENT0_API_KEY, optionally AGENT0_TELEMETRY_ENDPOINT (defaults to prod). + * Run: npm test -- telemetry-sdk + * + * Tests that require local Supabase (SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY) and + * ingest-telemetry running (e.g. in agent0-dashboard: npx supabase functions serve): + * - searchAgents returns array and emits telemetry (DB check: search.query) + * - getAgent returns agent or null and emits telemetry (DB check: agent.fetched) + * - loadAgent returns Agent and emits telemetry (DB check: agent.loaded, only when agent URI is HTTP/IPFS) + * - searchFeedback emits telemetry (DB check: feedback.searched) + * - getReputationSummary emits telemetry (DB check: reputation.summary.fetched) + * - telemetry events are written to the database (spec coverage) + * Apply seed-telemetry-test-user.sql so the test API key exists. + * + * Spec coverage (read-only, no signer): + * search.query, agent.fetched, agent.loaded, feedback.searched, reputation.summary.fetched + * Write/lifecycle events (agent.registered, feedback.given, etc.) require signer/agent and are not covered here. + */ + +import { createClient } from '@supabase/supabase-js'; +import { SDK } from '../src/index.js'; +import { + CHAIN_ID, + RPC_URL, + SUBGRAPH_URL, + AGENT_ID, + AGENT0_API_KEY, + AGENT0_TELEMETRY_ENDPOINT, + SUPABASE_URL, + SUPABASE_SERVICE_ROLE_KEY, + printConfig, +} from './config.js'; + +const HAS_API_KEY = Boolean(AGENT0_API_KEY && AGENT0_API_KEY.trim() !== ''); +const HAS_SUPABASE = + Boolean(SUPABASE_URL && SUPABASE_URL.trim() !== '') && + Boolean(SUPABASE_SERVICE_ROLE_KEY && SUPABASE_SERVICE_ROLE_KEY.trim() !== ''); +const describeMaybe = HAS_API_KEY ? describe : describe.skip; +const itMaybe = HAS_API_KEY ? it : it.skip; +const itDb = HAS_API_KEY && HAS_SUPABASE ? it : it.skip; + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** Asserts that at least one telemetry event of type exists in DB after since. Only runs when HAS_SUPABASE. */ +async function assertEventInDb(eventType: string, since: string): Promise { + if (!HAS_SUPABASE || !SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) return; + await delay(6000); + const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); + const { data, error } = await supabase + .from('telemetry_events') + .select('event_type') + .gte('timestamp', since) + .eq('event_type', eventType) + .limit(1); + expect(error).toBeNull(); + if (!data || data.length === 0) { + throw new Error( + `No telemetry event "${eventType}" found. Ensure Supabase and ingest-telemetry are running (e.g. in agent0-dashboard: npx supabase functions serve).` + ); + } +} + +describeMaybe('SDK with telemetry (apiKey + telemetryEndpoint)', () => { + let sdk: SDK; + + beforeAll(() => { + printConfig(); + sdk = new SDK({ + chainId: CHAIN_ID, + rpcUrl: RPC_URL, + subgraphUrl: SUBGRAPH_URL, + apiKey: AGENT0_API_KEY, + telemetryEndpoint: AGENT0_TELEMETRY_ENDPOINT || undefined, + }); + }); + + itMaybe('searchAgents returns array and emits telemetry', async () => { + const since = new Date().toISOString(); + const result = await sdk.searchAgents({}, { sort: ['updatedAt:desc'] }); + expect(Array.isArray(result)).toBe(true); + if (result.length > 0) { + expect(typeof result[0].chainId).toBe('number'); + expect(typeof result[0].agentId).toBe('string'); + expect(result[0].agentId).toMatch(/^\d+:\d+$/); + } + await assertEventInDb('search.query', since); + }, 15_000); + + itMaybe('getAgent returns agent or null and emits telemetry', async () => { + const since = new Date().toISOString(); + const agent = await sdk.getAgent(AGENT_ID); + if (agent) { + expect(agent.agentId).toBe(AGENT_ID); + expect(typeof agent.chainId).toBe('number'); + expect(typeof agent.name).toBe('string'); + } + await assertEventInDb('agent.fetched', since); + }, 15_000); + + itMaybe('loadAgent returns Agent and emits telemetry (when agent URI is HTTP/IPFS)', async () => { + const since = new Date().toISOString(); + let emitted = false; + try { + const agent = await sdk.loadAgent(AGENT_ID); + expect(agent).toBeDefined(); + expect(agent.agentId).toBe(AGENT_ID); + expect(typeof agent.name).toBe('string'); + emitted = true; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + // Skip if test agent uses data: URI that we don't support or that is malformed + if (msg.includes('Data URIs are not supported') || msg.includes('Invalid base64 payload in data URI')) { + return; + } + throw e; + } + if (emitted) await assertEventInDb('agent.loaded', since); + }, 15_000); + + itMaybe('searchFeedback emits telemetry', async () => { + const since = new Date().toISOString(); + const result = await sdk.searchFeedback({ agentId: AGENT_ID }); + expect(Array.isArray(result)).toBe(true); + await assertEventInDb('feedback.searched', since); + }, 15_000); + + itMaybe('getReputationSummary emits telemetry', async () => { + const since = new Date().toISOString(); + const summary = await sdk.getReputationSummary(AGENT_ID); + expect(summary).toBeDefined(); + expect(typeof summary.count).toBe('number'); + expect(typeof summary.averageValue).toBe('number'); + await assertEventInDb('reputation.summary.fetched', since); + }, 15_000); + + itDb('telemetry events are written to the database (spec coverage)', async () => { + const since = new Date(Date.now() - 120_000).toISOString(); + await sdk.searchAgents({}, { sort: ['updatedAt:desc'] }); + await sdk.getAgent(AGENT_ID); + let loadAgentEmitted = false; + try { + await sdk.loadAgent(AGENT_ID); + loadAgentEmitted = true; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (!msg.includes('Data URIs are not supported') && !msg.includes('Invalid base64 payload in data URI')) throw e; + } + await sdk.searchFeedback({ agentId: AGENT_ID }); + await sdk.getReputationSummary(AGENT_ID); + await delay(6000); + + const expectedTypes = [ + 'search.query', + 'agent.fetched', + 'feedback.searched', + 'reputation.summary.fetched', + ]; + if (loadAgentEmitted) expectedTypes.push('agent.loaded'); + + const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); + const { data: events, error } = await supabase + .from('telemetry_events') + .select('event_type, payload, timestamp') + .gte('timestamp', since) + .in('event_type', expectedTypes) + .order('timestamp', { ascending: false }); + + expect(error).toBeNull(); + expect(events).toBeDefined(); + const types = (events || []).map((e) => e.event_type); + if (types.length === 0) { + throw new Error( + 'No telemetry events found. Ensure Edge Functions are served (e.g. in agent0-dashboard run: npx supabase functions serve) and ingest-telemetry is reachable at AGENT0_TELEMETRY_ENDPOINT.' + ); + } + for (const t of expectedTypes) { + expect(types).toContain(t); + } + + const searchEvent = (events || []).find((e) => e.event_type === 'search.query'); + if (searchEvent?.payload && typeof searchEvent.payload === 'object') { + expect(Array.isArray((searchEvent.payload as { results?: unknown }).results)).toBe(true); + } + }, 25_000); +}); + +describe('SDK without apiKey (no telemetry)', () => { + it('constructs and searchAgents works', async () => { + const sdk = new SDK({ + chainId: CHAIN_ID, + rpcUrl: RPC_URL, + subgraphUrl: SUBGRAPH_URL, + }); + const result = await sdk.searchAgents({}, { sort: ['updatedAt:desc'] }); + expect(Array.isArray(result)).toBe(true); + }); +});