diff --git a/aap-mcp.sample.yaml b/aap-mcp.sample.yaml index c35b365..6744dc0 100644 --- a/aap-mcp.sample.yaml +++ b/aap-mcp.sample.yaml @@ -11,6 +11,14 @@ # Configuration for Prometheus metrics endpoint (defaults to false if not specified) # enable_metrics: true +# Configuration for analytics reporting (defaults to false if not specified) +# Sends anonymized usage metrics to Segment.com every 5 hours +# enable_analytics: false + +# Segment.com write key for analytics (required if enable_analytics is true) +# Lower priority than SEGMENT_WRITE_KEY environment variable +# segment_write_key: "your-segment-write-key-here" + # Configuration for base URL (defaults to https://localhost if not specified) # Lower priority than BASE_URL environment variable # base_url: "https://localhost:8443" diff --git a/package-lock.json b/package-lock.json index 6191242..0bf65f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.18.1", + "@segment/analytics-node": "^2.3.0", "@types/js-yaml": "^4.0.9", "@types/node": "^24.5.2", "cors": "^2.8.5", @@ -115,42 +116,6 @@ "url": "https://github.com/sponsors/philsturgeon" } }, - "node_modules/@apidevtools/swagger-parser/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@apidevtools/swagger-parser/node_modules/ajv-draft-04": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", - "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", - "license": "MIT", - "peerDependencies": { - "ajv": "^8.5.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/@apidevtools/swagger-parser/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -769,13 +734,35 @@ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", "license": "MIT" }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@lukeed/uuid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@lukeed/uuid/-/uuid-2.0.1.tgz", + "integrity": "sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==", + "license": "MIT", + "dependencies": { + "@lukeed/csprng": "^1.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.2.tgz", - "integrity": "sha512-6rqTdFt67AAAzln3NOKsXRmv5ZzPkgbfaebKBqUbts7vK1GZudqnrun5a8d3M/h955cam9RHZ6Jb4Y1XhnmFPg==", + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.21.0.tgz", + "integrity": "sha512-YFBsXJMFCyI1zP98u7gezMFKX4lgu/XpoZJk7ufI6UlFKXLj2hAMUuRlQX/nrmIPOmhRrG6tw2OQ2ZA/ZlXYpQ==", "license": "MIT", "dependencies": { - "ajv": "^6.12.6", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", @@ -790,6 +777,14 @@ }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + } } }, "node_modules/@opentelemetry/api": { @@ -865,42 +860,6 @@ "openapi-types": ">=7" } }, - "node_modules/@readme/openapi-parser/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@readme/openapi-parser/node_modules/ajv-draft-04": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", - "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", - "license": "MIT", - "peerDependencies": { - "ajv": "^8.5.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/@readme/openapi-parser/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/@readme/openapi-schemas": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@readme/openapi-schemas/-/openapi-schemas-3.1.0.tgz", @@ -1235,6 +1194,45 @@ "win32" ] }, + "node_modules/@segment/analytics-core": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/@segment/analytics-core/-/analytics-core-1.8.2.tgz", + "integrity": "sha512-5FDy6l8chpzUfJcNlIcyqYQq4+JTUynlVoCeCUuVz+l+6W0PXg+ljKp34R4yLVCcY5VVZohuW+HH0VLWdwYVAg==", + "license": "MIT", + "dependencies": { + "@lukeed/uuid": "^2.0.0", + "@segment/analytics-generic-utils": "1.2.0", + "dset": "^3.1.4", + "tslib": "^2.4.1" + } + }, + "node_modules/@segment/analytics-generic-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@segment/analytics-generic-utils/-/analytics-generic-utils-1.2.0.tgz", + "integrity": "sha512-DfnW6mW3YQOLlDQQdR89k4EqfHb0g/3XvBXkovH1FstUN93eL1kfW9CsDcVQyH3bAC5ZsFyjA/o/1Q2j0QeoWw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.1" + } + }, + "node_modules/@segment/analytics-node": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@segment/analytics-node/-/analytics-node-2.3.0.tgz", + "integrity": "sha512-fOXLL8uY0uAWw/sTLmezze80hj8YGgXXlAfvSS6TUmivk4D/SP0C0sxnbpFdkUzWg2zT64qWIZj26afEtSnxUA==", + "license": "MIT", + "dependencies": { + "@lukeed/uuid": "^2.0.0", + "@segment/analytics-core": "1.8.2", + "@segment/analytics-generic-utils": "1.2.0", + "buffer": "^6.0.3", + "jose": "^5.1.0", + "node-fetch": "^2.6.7", + "tslib": "^2.4.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -1556,21 +1554,52 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -1632,6 +1661,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/bintrees": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", @@ -1668,6 +1717,30 @@ "balanced-match": "^1.0.0" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1977,6 +2050,15 @@ "url": "https://dotenvx.com" } }, + "node_modules/dset": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2233,12 +2315,6 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "license": "MIT" - }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -2545,6 +2621,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2651,6 +2747,15 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -2681,9 +2786,9 @@ } }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/jsonc-parser": { @@ -3259,15 +3364,6 @@ "node": ">= 0.10" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -3982,6 +4078,12 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/tsx": { "version": "4.20.6", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", @@ -4044,15 +4146,6 @@ "node": ">= 0.8" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index ca799ae..68df2dd 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.18.1", + "@segment/analytics-node": "^2.3.0", "@types/js-yaml": "^4.0.9", "@types/node": "^24.5.2", "cors": "^2.8.5", diff --git a/src/analytics.ts b/src/analytics.ts new file mode 100644 index 0000000..1237d97 --- /dev/null +++ b/src/analytics.ts @@ -0,0 +1,319 @@ +import { Analytics } from "@segment/analytics-node"; +import { randomUUID } from "node:crypto"; +import crypto from "crypto"; +import { register } from "prom-client"; + +export interface AnalyticsEvent { + anonymousId: string; + event: string; + properties: Record; + timestamp?: Date; +} + +export interface MetricsSummary { + totalToolExecutions: number; + successfulExecutions: number; + failedExecutions: number; + topTools: Array<{ name: string; count: number }>; + serviceUsage: Record; + errorTypes: Record; + avgExecutionTime: number; +} + +export class AnalyticsService { + private analytics: Analytics | null = null; + private enabled: boolean = false; + private writeKey: string | null = null; + private reportingInterval: NodeJS.Timeout | null = null; + + constructor(writeKey?: string) { + if (writeKey) { + this.writeKey = writeKey; + console.log("[Analytics] Creating Segment.com Analytics client..."); + this.analytics = new Analytics({ + writeKey, + flushAt: 20, // Larger batch size for periodic reporting + flushInterval: 60000, // Flush every minute for periodic data + }); + this.enabled = true; + console.log( + "[Analytics] Analytics client created (batch size: 20, flush interval: 60s)", + ); + } + } + + isEnabled(): boolean { + return this.enabled && this.analytics !== null; + } + + // Generate anonymized installation ID for this service instance + private generateInstallationId(): string { + // Use a combination of factors to create a stable but anonymous ID + const hostname = process.env.HOSTNAME || "localhost"; + const startTime = process.env.START_TIME || Date.now().toString(); + return crypto + .createHash("sha256") + .update(`${hostname}-${startTime}`) + .digest("hex") + .substring(0, 16); + } + + // Parse Prometheus metrics to extract usage data + private async parsePrometheusMetrics(): Promise { + const metricsString = await register.metrics(); + const lines = metricsString.split("\n"); + + let totalExecutions = 0; + let successfulExecutions = 0; + let failedExecutions = 0; + const toolCounts: Record = {}; + const serviceCounts: Record = {}; + const errorCounts: Record = {}; + let totalDuration = 0; + let durationSamples = 0; + + for (const line of lines) { + // Parse tool execution counts + if (line.startsWith("mcp_tool_executions_total{")) { + const match = line.match( + /tool_name="([^"]+)".*service="([^"]+)".*status="([^"]+)".*}\s+(\d+)/, + ); + if (match) { + const [, toolName, service, status, count] = match; + const countNum = parseInt(count); + + totalExecutions += countNum; + toolCounts[toolName] = (toolCounts[toolName] || 0) + countNum; + serviceCounts[service] = (serviceCounts[service] || 0) + countNum; + + if (status === "success") { + successfulExecutions += countNum; + } else { + failedExecutions += countNum; + } + } + } + + // Parse error counts + if (line.startsWith("mcp_tool_errors_total{")) { + const match = line.match(/error_type="([^"]+)".*}\s+(\d+)/); + if (match) { + const [, errorType, count] = match; + errorCounts[errorType] = parseInt(count); + } + } + + // Parse execution duration + if (line.startsWith("mcp_tool_execution_duration_seconds_sum")) { + const match = line.match(/}\s+([\d.]+)/); + if (match) { + totalDuration += parseFloat(match[1]); + } + } + + if (line.startsWith("mcp_tool_execution_duration_seconds_count")) { + const match = line.match(/}\s+(\d+)/); + if (match) { + durationSamples += parseInt(match[1]); + } + } + } + + // Get top 10 tools by usage + const topTools = Object.entries(toolCounts) + .sort(([, a], [, b]) => b - a) + .slice(0, 10) + .map(([name, count]) => ({ name, count })); + + return { + totalToolExecutions: totalExecutions, + successfulExecutions, + failedExecutions, + topTools, + serviceUsage: serviceCounts, + errorTypes: errorCounts, + avgExecutionTime: + durationSamples > 0 ? totalDuration / durationSamples : 0, + }; + } + + // Send aggregated metrics summary to Segment + private async sendMetricsSummary( + isInitialReport: boolean = false, + ): Promise { + if (!this.isEnabled()) return; + + const timestamp = new Date().toISOString(); + console.log( + `[Analytics] [${timestamp}] Collecting metrics for Segment.com...`, + ); + + try { + const summary = await this.parsePrometheusMetrics(); + const installationId = this.generateInstallationId(); + + // Skip if no usage to report, except for initial report (to confirm analytics is working) + if (summary.totalToolExecutions === 0 && !isInitialReport) { + console.log( + `[Analytics] [${timestamp}] No tool executions to report in this period - skipping`, + ); + return; + } + + if (isInitialReport && summary.totalToolExecutions === 0) { + console.log( + `[Analytics] [${timestamp}] Initial report: No executions yet, but sending to confirm analytics is working`, + ); + } + + console.log( + `[Analytics] [${timestamp}] Preparing to send usage summary to Segment.com:`, + ); + console.log( + `[Analytics] - Total executions: ${summary.totalToolExecutions}`, + ); + console.log( + `[Analytics] - Successful: ${summary.successfulExecutions}`, + ); + console.log(`[Analytics] - Failed: ${summary.failedExecutions}`); + console.log( + `[Analytics] - Success rate: ${(summary.totalToolExecutions > 0 + ? (summary.successfulExecutions / summary.totalToolExecutions) * 100 + : 0 + ).toFixed(1)}%`, + ); + console.log( + `[Analytics] - Top tools: ${summary.topTools + .slice(0, 3) + .map((t) => t.name) + .join(", ")}`, + ); + console.log(`[Analytics] - Installation ID: ${installationId}`); + + const event: AnalyticsEvent = { + anonymousId: installationId, + event: "MCP Server Usage Summary", + properties: { + total_executions: summary.totalToolExecutions, + successful_executions: summary.successfulExecutions, + failed_executions: summary.failedExecutions, + success_rate: + summary.totalToolExecutions > 0 + ? summary.successfulExecutions / summary.totalToolExecutions + : 0, + top_tools: summary.topTools, + service_usage: summary.serviceUsage, + error_types: summary.errorTypes, + avg_execution_time_seconds: summary.avgExecutionTime, + reporting_period_hours: 5, + timestamp: new Date().toISOString(), + }, + timestamp: new Date(), + }; + + console.log(`[Analytics] [${timestamp}] Sending event to Segment.com...`); + await this.analytics?.track(event); + console.log( + `[Analytics] [${timestamp}] ✓ Successfully sent usage summary to Segment.com`, + ); + } catch (error) { + console.error( + `[Analytics] [${timestamp}] ✗ Failed to send metrics summary to Segment.com:`, + error, + ); + } + } + + // Start periodic reporting every 5 hours + startPeriodicReporting(): void { + if (!this.isEnabled()) return; + + // Clear any existing interval + this.stopPeriodicReporting(); + + const firstReportTime = new Date(Date.now() + 60000).toISOString(); + console.log( + `[Analytics] Periodic reporting enabled - sending reports to Segment.com every 5 hours`, + ); + console.log(`[Analytics] First report scheduled for: ${firstReportTime}`); + + // Send initial report after 1 minute startup delay + setTimeout(() => { + console.log( + "[Analytics] Initial report triggered (1 minute after startup)", + ); + this.sendMetricsSummary(true); // isInitialReport = true + }, 60000); + + // Set up 5-hour interval (5 * 60 * 60 * 1000 ms) + this.reportingInterval = setInterval( + () => { + console.log("[Analytics] Periodic report triggered (5-hour interval)"); + this.sendMetricsSummary(); + }, + 5 * 60 * 60 * 1000, + ); + } + + // Stop periodic reporting + stopPeriodicReporting(): void { + if (this.reportingInterval) { + clearInterval(this.reportingInterval); + this.reportingInterval = null; + console.log( + "[Analytics] Periodic reporting stopped - no more data will be sent to Segment.com", + ); + } + } + + // Send final report and flush + async flush(): Promise { + if (!this.isEnabled()) return; + + console.log("[Analytics] Flushing analytics data before shutdown..."); + + try { + // Send final metrics summary before shutdown + console.log( + "[Analytics] Sending final metrics summary to Segment.com...", + ); + await this.sendMetricsSummary(); + + console.log( + "[Analytics] Closing Segment.com connection and flushing remaining events...", + ); + await this.analytics?.closeAndFlush(); + console.log( + "[Analytics] ✓ Successfully flushed all analytics data to Segment.com", + ); + } catch (error) { + console.error("[Analytics] ✗ Failed to flush analytics data:", error); + } + } +} + +// Create a default instance that can be configured later +export let analyticsService: AnalyticsService = new AnalyticsService(); + +// Function to initialize analytics with configuration +export function initializeAnalytics(writeKey?: string): void { + if (writeKey) { + console.log("[Analytics] Initializing analytics service..."); + console.log( + `[Analytics] Segment.com write key: ${writeKey.substring(0, 8)}...`, + ); + analyticsService = new AnalyticsService(writeKey); + analyticsService.startPeriodicReporting(); + console.log("[Analytics] ✓ Analytics service initialized successfully"); + console.log( + "[Analytics] Anonymous usage metrics will be sent to Segment.com every 5 hours", + ); + } else { + console.log( + "[Analytics] Analytics service disabled - no Segment write key provided", + ); + console.log( + "[Analytics] To enable analytics, set SEGMENT_WRITE_KEY environment variable or segment_write_key in config", + ); + } +} diff --git a/src/index.ts b/src/index.ts index e6b6a17..e97b237 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,6 +59,8 @@ interface AapMcpConfig { "ignore-certificate-errors"?: boolean; enable_ui?: boolean; enable_metrics?: boolean; + enable_analytics?: boolean; + segment_write_key?: string; allow_write_operations?: boolean; base_url?: string; services?: ServiceConfig[]; @@ -82,12 +84,32 @@ const loadConfig = (): AapMcpConfig => { const localConfig = loadConfig(); const allCategories: Record = localConfig.categories; +// Helper function to get boolean configuration with environment variable override +const getBooleanConfig = ( + envVar: string, + configValue: boolean | undefined, +): boolean => { + return process.env[envVar] !== undefined + ? process.env[envVar]!.toLowerCase() === "true" + : (configValue ?? false); +}; + // Configuration constants (with priority: env var > config file > default) const CONFIG = { BASE_URL: process.env.BASE_URL || localConfig.base_url || "https://localhost", MCP_PORT: process.env.MCP_PORT ? parseInt(process.env.MCP_PORT, 10) : 3000, FALLBACK_BEARER_TOKEN: process.env.BEARER_TOKEN_OAUTH2_AUTHENTICATION, -} as const; + segment_write_key: + process.env.SEGMENT_WRITE_KEY || localConfig.segment_write_key, + enable_analytics: getBooleanConfig( + "ENABLE_ANALYTICS", + localConfig.enable_analytics, + ), + enable_metrics: getBooleanConfig( + "ENABLE_METRICS", + localConfig.enable_metrics, + ), +}; // Log entries size limit for /logs endpoint const logEntriesSizeLimit = 10000; @@ -95,16 +117,6 @@ const logEntriesSizeLimit = 10000; // Log configuration settings console.log(`BASE_URL: ${CONFIG.BASE_URL}`); -// Helper function to get boolean configuration with environment variable override -const getBooleanConfig = ( - envVar: string, - configValue: boolean | undefined, -): boolean => { - return process.env[envVar] !== undefined - ? process.env[envVar]!.toLowerCase() === "true" - : (configValue ?? false); -}; - // Get configuration settings (priority: env var > config file > default) const recordApiQueries = getBooleanConfig( "RECORD_API_QUERIES", @@ -130,7 +142,9 @@ const allowWriteOperations = getBooleanConfig( localConfig.allow_write_operations, ); console.log( - `Write operations (POST/DELETE/PATCH): ${allowWriteOperations ? "ENABLED" : "DISABLED"}`, + `Write operations (POST/DELETE/PATCH): ${ + allowWriteOperations ? "ENABLED" : "DISABLED" + }`, ); // Initialize allowed operations list based on configuration @@ -141,7 +155,11 @@ const allowedOperations = allowWriteOperations // Get services configuration const servicesConfig = localConfig.services || []; console.log( - `Services configured: ${servicesConfig.length > 0 ? servicesConfig.map((s) => s.name).join(", ") : "none"}`, + `Services configured: ${ + servicesConfig.length > 0 + ? servicesConfig.map((s) => s.name).join(", ") + : "none" + }`, ); // Configure HTTPS certificate validation globally @@ -227,7 +245,9 @@ const validateTokenAndGetPermissions = async ( } catch (error) { console.error("Token validation failed:", error); throw new Error( - `Token validation failed: ${error instanceof Error ? error.message : String(error)}`, + `Token validation failed: ${ + error instanceof Error ? error.message : String(error) + }`, ); } }; @@ -365,7 +385,9 @@ const generateTools = async (): Promise => { const csvRows = toolsWithSize .map( (tool) => - `${tool.name},${tool.size},"${tool.description}",${tool.pathTemplate},${tool.service || "unknown"}`, + `${tool.name},${tool.size},"${tool.description}",${tool.pathTemplate},${ + tool.service || "unknown" + }`, ) .join("\n"); const csvContent = csvHeader + csvRows; @@ -481,7 +503,11 @@ server.setRequestHandler(ListToolsRequestSchema, async (request, extra) => { ? ` (override: ${categoryOverride})` : ""; console.log( - `Returning ${filteredTools.length} tools for ${categoryType} category${overrideInfo} (session: ${sessionId || "none"})`, + `Returning ${ + filteredTools.length + } tools for ${categoryType} category${overrideInfo} (session: ${ + sessionId || "none" + })`, ); return { @@ -591,6 +617,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { result, response.status, startTime, + sessionId, + userAgent, ); } @@ -624,11 +652,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { { error: error instanceof Error ? error.message : String(error) }, response?.status || 0, startTime, + sessionId, + userAgent, ); } throw new Error( - `Tool execution failed: ${error instanceof Error ? error.message : String(error)}`, + `Tool execution failed: ${ + error instanceof Error ? error.message : String(error) + }`, ); } }); @@ -675,7 +707,11 @@ const mcpPostHandler = async ( sessionIdGenerator: () => randomUUID(), onsessioninitialized: async (sessionId: string) => { console.log( - `Session initialized with ID: ${sessionId}${categoryOverride ? ` with category override: ${categoryOverride}` : ""}`, + `Session initialized with ID: ${sessionId}${ + categoryOverride + ? ` with category override: ${categoryOverride}` + : "" + }`, ); transports[sessionId] = transport; @@ -946,7 +982,9 @@ if (enableUI) { const csvRows = allTools .map( (tool) => - `${tool.name},${tool.size},"${tool.description?.replace(/"/g, '""') || ""}",${tool.pathTemplate},${tool.service || "unknown"}`, + `${tool.name},${tool.size},"${ + tool.description?.replace(/"/g, '""') || "" + }",${tool.pathTemplate},${tool.service || "unknown"}`, ) .join("\n"); const csvContent = csvHeader + csvRows; @@ -975,7 +1013,9 @@ if (enableUI) { name: categoryName, displayName: categoryName.charAt(0).toUpperCase() + categoryName.slice(1), - description: `${categoryName.charAt(0).toUpperCase() + categoryName.slice(1)} category with specific tool access`, + description: `${ + categoryName.charAt(0).toUpperCase() + categoryName.slice(1) + } category with specific tool access`, tools: filterToolsByCategory(allTools, categoryTools), color: getCategoryColor(categoryName), toolCount: 0, // Will be calculated below @@ -1200,7 +1240,11 @@ if (enableUI) { (sum, tool) => sum + (tool.logs?.length || 0), 0, ), - description: `${serviceName.charAt(0).toUpperCase() + serviceName.slice(1)} service providing ${tools.length} tools for automation and management tasks.`, + description: `${ + serviceName.charAt(0).toUpperCase() + serviceName.slice(1) + } service providing ${ + tools.length + } tools for automation and management tasks.`, }), ); @@ -1426,11 +1470,7 @@ app.get("/api/v1/health", (req, res) => { }); // Prometheus metrics endpoint (conditional based on config) -const enableMetrics = getBooleanConfig( - "ENABLE_METRICS", - localConfig.enable_metrics, -); -if (enableMetrics) { +if (CONFIG.enable_metrics) { app.get("/metrics", async (req, res) => { try { res.set("Content-Type", metricsService.getContentType()); @@ -1447,6 +1487,14 @@ if (enableMetrics) { } async function main(): Promise { + // Initialize analytics service if configured + const segmentWriteKey = + process.env.SEGMENT_WRITE_KEY || CONFIG.segment_write_key; + if (segmentWriteKey && CONFIG.enable_analytics !== false) { + const { initializeAnalytics } = await import("./analytics.js"); + initializeAnalytics(segmentWriteKey); + } + // Initialize tools before starting server console.log("Loading OpenAPI specifications and generating tools..."); allTools = await generateTools(); @@ -1461,13 +1509,31 @@ async function main(): Promise { }); console.log(`Successfully loaded ${allTools.length} tools`); + + // Set up metrics for active tools count per service + if (CONFIG.enable_metrics !== false) { + const { metricsService } = await import("./metrics.js"); + const toolsByService = allTools.reduce( + (acc, tool) => { + const service = tool.service || "unknown"; + acc[service] = (acc[service] || 0) + 1; + return acc; + }, + {} as Record, + ); + + Object.entries(toolsByService).forEach(([service, count]) => { + metricsService.setActiveTools(service, count); + }); + } + const PORT = process.env.MCP_PORT || 3000; app.listen(PORT, () => { console.log(`AAP MCP Server running on port ${PORT}`); console.log(`Web UI available at: http://localhost:${PORT}`); console.log(`MCP endpoint available at: http://localhost:${PORT}/mcp`); - if (enableMetrics) { + if (CONFIG.enable_metrics) { console.log( `Metrics endpoint available at: http://localhost:${PORT}/metrics`, ); @@ -1494,6 +1560,18 @@ process.on("SIGINT", async () => { } } + // Stop periodic reporting and flush analytics before shutdown + try { + const { analyticsService } = await import("./analytics.js"); + if (analyticsService.isEnabled()) { + console.log("Stopping analytics reporting and flushing data..."); + analyticsService.stopPeriodicReporting(); + await analyticsService.flush(); + } + } catch (error) { + console.error("Error stopping analytics:", error); + } + console.log("Server shutdown complete"); process.exit(0); });