From d87f5f6a7e43f28dc46568d4938bada7fa210398 Mon Sep 17 00:00:00 2001 From: Arkadiusz Komarzewski Date: Fri, 13 Feb 2026 10:28:04 +0100 Subject: [PATCH] Add Glean telemetry to MCP server Handcraft minimal Glean events pings since @mozilla/glean has no Node.js entry point. Tracks initialize and tool_call events. Also fix Netlify venv setup: drop get-pip.py in favor of standard venv with ensurepip (pip 26 no longer bundles setuptools). --- .netlify/mcp.js | 24 + .netlify/telemetry.js | 108 ++++ docs/mcp-server.md | 36 ++ netlify.toml | 7 +- package-lock.json | 314 +++++++---- package.json | 1 + tests/fixtures/glean.1.schema.json | 826 +++++++++++++++++++++++++++++ tests/mcp.test.js | 261 ++++++++- tests/telemetry.test.js | 281 ++++++++++ 9 files changed, 1757 insertions(+), 101 deletions(-) create mode 100644 .netlify/telemetry.js create mode 100644 tests/fixtures/glean.1.schema.json create mode 100644 tests/telemetry.test.js diff --git a/.netlify/mcp.js b/.netlify/mcp.js index c48340aa3..aef8314fc 100644 --- a/.netlify/mcp.js +++ b/.netlify/mcp.js @@ -3,12 +3,18 @@ * Implements MCP JSON-RPC protocol for discovering Glean telemetry metadata. */ +const { submitTelemetryEvent } = require("./telemetry"); + const PROBEINFO_BASE_URL = "https://probeinfo.telemetry.mozilla.org"; const ANNOTATIONS_URL = "https://mozilla.github.io/glean-annotations/api.json"; // Cache for annotations (shared across invocations in same instance) let cachedAnnotations = null; +// MCP client info captured during initialize +let mcpClientName = null; +let mcpClientVersion = null; + /** * Fetch JSON from a URL */ @@ -457,8 +463,16 @@ async function handleJsonRpc(request) { const { id, method, params } = request; + console.log("MCP request params:", JSON.stringify(params, null, 2)); + switch (method) { case "initialize": + mcpClientName = params?.clientInfo?.name || null; + mcpClientVersion = params?.clientInfo?.version || null; + await submitTelemetryEvent("mcp", "initialize", { + client_name: mcpClientName, + client_version: mcpClientVersion, + }); return { jsonrpc: "2.0", id, @@ -478,6 +492,11 @@ async function handleJsonRpc(request) { params.name, params.arguments || {} ); + await submitTelemetryEvent("mcp", "tool_call", { + tool_name: params.name, + app_name: (params.arguments || {}).app_name, + success: "true", + }); return { jsonrpc: "2.0", id, @@ -486,6 +505,11 @@ async function handleJsonRpc(request) { }, }; } catch (error) { + await submitTelemetryEvent("mcp", "tool_call", { + tool_name: params.name, + app_name: (params.arguments || {}).app_name, + success: "false", + }); return { jsonrpc: "2.0", id, diff --git a/.netlify/telemetry.js b/.netlify/telemetry.js new file mode 100644 index 000000000..2fcaf8313 --- /dev/null +++ b/.netlify/telemetry.js @@ -0,0 +1,108 @@ +/** + * Lightweight Glean telemetry for the MCP server. + * + * We can't use the Glean JS SDK here because @mozilla/glean only ships + * browser bundles — there's no Node.js entry point. Since this runs in + * a Netlify Function (Node.js), we handcraft a minimal events ping and + * POST it directly to the ingestion endpoint. + */ + +const { randomUUID } = require("crypto"); + +const DEFAULT_APP_ID = "glean-dictionary-dev"; +const DEFAULT_INGESTION_URL = "https://incoming.telemetry.mozilla.org"; + +/** + * Map Netlify CONTEXT env var to app_channel. + */ +function getAppChannel() { + const context = process.env.CONTEXT; + if (context === "production") return "production"; + if (context === "deploy-preview" || context === "branch-deploy") + return "deploy-preview"; + return "development"; +} + +/** + * Build a Glean events ping payload. + * + * @param {Array} events - Array of event objects with category, name, extra + * @returns {Object} A valid glean.1 ping payload + */ +function buildPing(events) { + const now = new Date().toISOString(); + + return { + ping_info: { + seq: 0, + start_time: now, + end_time: now, + }, + client_info: { + app_build: "Unknown", + app_display_version: "1.0.0", + app_channel: getAppChannel(), + architecture: "n/a", + os: "n/a", + os_version: "n/a", + telemetry_sdk_build: "glean-mcp/1.0.0", + first_run_date: now, + locale: "und", + }, + events: events.map((event, index) => ({ + category: event.category, + name: event.name, + timestamp: index, + extra: event.extra, + })), + }; +} + +/** + * Submit a telemetry event to the Glean ingestion endpoint. + * + * @param {string} category - Event category (e.g. "mcp") + * @param {string} name - Event name (e.g. "tool_call") + * @param {Object} extra - Extra key-value pairs (all string values) + * @returns {Promise} Resolves silently; never throws. + */ +async function submitTelemetryEvent(category, name, extra = {}) { + try { + const appId = process.env.GLEAN_APPLICATION_ID || DEFAULT_APP_ID; + const ingestionUrl = + process.env.GLEAN_INGESTION_URL || DEFAULT_INGESTION_URL; + const debugTag = process.env.GLEAN_DEBUG_VIEW_TAG; + + // Filter out undefined/null extra values + const cleanExtra = {}; + for (const [k, v] of Object.entries(extra)) { + if (v != null) { + cleanExtra[k] = String(v); + } + } + + const ping = buildPing([{ category, name, extra: cleanExtra }]); + const uuid = randomUUID(); + const url = `${ingestionUrl}/submit/${appId}/events/1/${uuid}`; + + const headers = { + "Content-Type": "application/json; charset=utf-8", + Date: new Date().toUTCString(), + "X-Telemetry-Agent": "Glean/handcrafted", + }; + + if (debugTag) { + headers["X-Debug-ID"] = debugTag; + } + + await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(ping), + }); + } catch { + // Telemetry must never break MCP responses — silently ignore all errors. + } +} + +module.exports = { submitTelemetryEvent, buildPing }; diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 7246cf84e..da14b6c70 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -73,6 +73,42 @@ Once connected, you can ask Claude: claude mcp add --transport http glean-dictionary https://dictionary.telemetry.mozilla.org/mcp ``` +## Telemetry + +The MCP server sends lightweight Glean telemetry to track usage. We can't use +the Glean JS SDK because `@mozilla/glean` only ships browser bundles — there's +no Node.js entry point. Instead, we handcraft a minimal events ping and POST it +directly to the ingestion endpoint (see `.netlify/telemetry.js`). + +**Events:** + +| Event | Extras | Description | +| ---------------- | ---------------------------------- | ------------------------------ | +| `mcp.initialize` | `client_name`, `client_version` | Fired on each MCP `initialize` | +| `mcp.tool_call` | `tool_name`, `app_name`, `success` | Fired on each `tools/call` | + +Pings are submitted under the same `glean_dictionary` app ID as the frontend, +with `app_channel` set from the Netlify `CONTEXT` env var (`production`, +`deploy-preview`, or `development`). + +**Environment variables** (all optional): + +| Variable | Default | Description | +| ---------------------- | ---------------------------------------- | --------------------------------------- | +| `GLEAN_APPLICATION_ID` | `glean-dictionary-dev` | App ID in the submission URL | +| `GLEAN_INGESTION_URL` | `https://incoming.telemetry.mozilla.org` | Ingestion endpoint | +| `GLEAN_DEBUG_VIEW_TAG` | _(unset)_ | Adds `X-Debug-ID` header for debug view | +| `CONTEXT` | _(unset)_ | Netlify build context → `app_channel` | + +**Debug pings locally:** + +```bash +GLEAN_DEBUG_VIEW_TAG=mcp-test npx netlify dev +``` + +Then check https://debug-ping-preview.firebaseapp.com/pings/mcp-test after +making MCP calls. + ## Data Sources - **Probeinfo API**: `probeinfo.telemetry.mozilla.org` - metrics, pings, apps diff --git a/netlify.toml b/netlify.toml index c254a6751..95789885a 100644 --- a/netlify.toml +++ b/netlify.toml @@ -4,10 +4,8 @@ command = """ pip install -e . ./scripts/gd build-metadata # glean.js currently requires a virtual environment - python -mvenv venv --without-pip - wget https://bootstrap.pypa.io/get-pip.py - venv/bin/python get-pip.py - venv/bin/pip install wheel + python3 -m venv venv + venv/bin/pip3 install setuptools wheel npm ci npm run build if [ "$STORYBOOK" ]; then @@ -21,6 +19,7 @@ publish = "public" [context.production] [context.production.environment] CONTEXT = "production" + GLEAN_APPLICATION_ID = "glean-dictionary" [[headers]] diff --git a/package-lock.json b/package-lock.json index e956e3815..8c2a1e97f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@storybook/svelte": "7.6.12", "@storybook/svelte-webpack5": "^7.6.13", "@testing-library/svelte": "^4.1.0", + "ajv": "^8.17.1", "autoprefixer": "^10.4.19", "babel-jest": "^29.3.1", "babel-loader": "8.2.5", @@ -2211,6 +2212,22 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "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" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@eslint/eslintrc/node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2244,6 +2261,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/@eslint/eslintrc/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==", + "dev": true + }, "node_modules/@eslint/eslintrc/node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -5198,22 +5221,6 @@ "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/builder-webpack5/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/@storybook/builder-webpack5/node_modules/ajv-keywords": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", @@ -5463,12 +5470,6 @@ "node": ">=8" } }, - "node_modules/@storybook/builder-webpack5/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==", - "dev": true - }, "node_modules/@storybook/builder-webpack5/node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -10527,15 +10528,15 @@ } }, "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==", "dev": true, "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", @@ -10559,37 +10560,6 @@ } } }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/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==", - "dev": true - }, - "node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "peerDependencies": { - "ajv": "^6.9.1" - } - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -13550,6 +13520,22 @@ "node": ">= 8" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "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" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/eslint/node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -13729,6 +13715,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/eslint/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==", + "dev": true + }, "node_modules/eslint/node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -14180,6 +14172,22 @@ "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "dev": true }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, "node_modules/fastq": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.9.0.tgz", @@ -14564,6 +14572,31 @@ "webpack": "^5.11.0" } }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "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" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -14636,6 +14669,12 @@ "node": ">=8" } }, + "node_modules/fork-ts-checker-webpack-plugin/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==", + "dev": true + }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -19290,9 +19329,9 @@ "dev": true }, "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==", "dev": true }, "node_modules/json-stable-stringify-without-jsonify": { @@ -21338,9 +21377,9 @@ } }, "node_modules/punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "engines": { "node": ">=6" @@ -22356,6 +22395,37 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "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" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/schema-utils/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==", + "dev": true + }, "node_modules/semiver": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/semiver/-/semiver-1.1.0.tgz", @@ -23259,6 +23329,22 @@ "node": ">=10.10.0" } }, + "node_modules/sveltedoc-parser/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "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" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/sveltedoc-parser/node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -23500,6 +23586,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/sveltedoc-parser/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==", + "dev": true + }, "node_modules/sveltedoc-parser/node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -23862,6 +23954,31 @@ } } }, + "node_modules/terser-webpack-plugin/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "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" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, "node_modules/terser-webpack-plugin/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -23885,6 +24002,12 @@ "node": ">= 10.13.0" } }, + "node_modules/terser-webpack-plugin/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==", + "dev": true + }, "node_modules/terser-webpack-plugin/node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -24431,9 +24554,9 @@ } }, "node_modules/uri-js": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", - "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "dependencies": { "punycode": "^2.1.0" @@ -24649,22 +24772,6 @@ } } }, - "node_modules/webpack-dev-middleware/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/webpack-dev-middleware/node_modules/ajv-keywords": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", @@ -24683,12 +24790,6 @@ "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "dev": true }, - "node_modules/webpack-dev-middleware/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==", - "dev": true - }, "node_modules/webpack-dev-middleware/node_modules/schema-utils": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", @@ -24755,6 +24856,37 @@ "integrity": "sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==", "dev": true }, + "node_modules/webpack/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "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" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/webpack/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==", + "dev": true + }, "node_modules/webpack/node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", diff --git a/package.json b/package.json index dfc09637c..b0ecda575 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@storybook/svelte": "7.6.12", "@storybook/svelte-webpack5": "^7.6.13", "@testing-library/svelte": "^4.1.0", + "ajv": "^8.17.1", "autoprefixer": "^10.4.19", "babel-jest": "^29.3.1", "babel-loader": "8.2.5", diff --git a/tests/fixtures/glean.1.schema.json b/tests/fixtures/glean.1.schema.json new file mode 100644 index 000000000..49957c487 --- /dev/null +++ b/tests/fixtures/glean.1.schema.json @@ -0,0 +1,826 @@ +{ + "$id": "moz://mozilla.org/schemas/glean/ping/1", + "$schema": "http://json-schema.org/draft-06/schema#", + "additionalProperties": false, + "description": "Schema for the ping content sent by Mozilla's glean telemetry SDK\n", + "mozPipelineMetadata": { + "json_object_path_regex": "metrics\\.object\\..*" + }, + "properties": { + "$schema": { + "enum": ["moz://mozilla.org/schemas/glean/ping/1"], + "type": "string" + }, + "client_info": { + "additionalProperties": false, + "properties": { + "android_sdk_version": { + "description": "The optional Android specific SDK version of the software running on this hardware device.", + "type": "string" + }, + "app_build": { + "description": "The build identifier generated by the CI system (e.g. \"1234/A\"). For language bindings that provide automatic detection for this value, (e.g. Android/Kotlin), in the unlikely event that the build identifier can not be retrieved from the OS, it is set to \"inaccessible\". For other language bindings, if the value was not provided through configuration, this metric gets set to `Unknown`.", + "type": "string" + }, + "app_channel": { + "description": "The channel the application is being distributed on.", + "type": "string" + }, + "app_display_version": { + "description": "The user visible version string (e.g. \"1.0.3\"). In the unlikely event that the display version can not be retrieved, it is set to \"inaccessible\".", + "type": "string" + }, + "architecture": { + "description": "The architecture of the device, (e.g. \"arm\", \"x86\").", + "type": "string" + }, + "attribution": { + "additionalProperties": false, + "properties": { + "campaign": { + "description": "The attribution campaign (e.g. 'mozilla-org').", + "type": "string" + }, + "content": { + "description": "The attribution content (e.g. 'firefoxview').", + "type": "string" + }, + "medium": { + "description": "The attribution medium (e.g. 'organic' for a search engine).", + "type": "string" + }, + "source": { + "description": "The attribution source (e.g. 'google-play').", + "type": "string" + }, + "term": { + "description": "The attribution term (e.g. 'browser with developer tools for android').", + "type": "string" + } + }, + "type": "object" + }, + "build_date": { + "description": "The date & time the application was built", + "format": "datetime", + "type": "string" + }, + "client_id": { + "description": "A UUID uniquely identifying the client.", + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$", + "type": "string" + }, + "device_manufacturer": { + "description": "The manufacturer of the device the application is running on. Not set if the device manufacturer can't be determined (e.g. on Desktop).", + "type": "string" + }, + "device_model": { + "description": "The model of the device the application is running on. On Android, this is Build.MODEL, the user-visible marketing name, like \"Pixel 2 XL\". Not set if the device model can't be determined (e.g. on Desktop).", + "type": "string" + }, + "distribution": { + "additionalProperties": false, + "properties": { + "name": { + "description": "The distribution name (e.g. 'MozillaOnline').", + "type": "string" + } + }, + "type": "object" + }, + "first_run_date": { + "description": "The date of the first run of the application.", + "format": "datetime", + "type": "string" + }, + "locale": { + "description": "The locale of the application during initialization (e.g. \"es-ES\"). If the locale can't be determined on the system, the value is [\"und\"](https://unicode.org/reports/tr35/#Unknown_or_Invalid_Identifiers), to indicate \"undetermined\".", + "type": "string" + }, + "os": { + "description": "The name of the operating system. Possible values: Android, iOS, Linux, Darwin, Windows, FreeBSD, NetBSD, OpenBSD, Solaris, unknown", + "type": "string" + }, + "os_version": { + "description": "The user-visible version of the operating system (e.g. \"1.2.3\"). If the version detection fails, this metric gets set to `Unknown`.", + "type": "string" + }, + "session_count": { + "description": "An optional running counter of the number of sessions for a client.", + "type": "integer" + }, + "session_id": { + "description": "An optional UUID uniquely identifying the client's current session.", + "type": "string" + }, + "telemetry_sdk_build": { + "description": "The version of the Glean SDK", + "type": "string" + }, + "windows_build_number": { + "description": "The optional Windows build number, reported by Windows (e.g. 22000) and not set for other platforms", + "type": "integer" + } + }, + "required": [ + "app_build", + "app_display_version", + "architecture", + "first_run_date", + "os", + "os_version", + "telemetry_sdk_build" + ], + "type": "object" + }, + "events": { + "items": { + "additionalProperties": false, + "properties": { + "category": { + "type": "string" + }, + "extra": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "type": "string" + }, + "type": "object" + }, + "name": { + "type": "string" + }, + "timestamp": { + "minimum": 0, + "type": "integer" + } + }, + "required": ["timestamp", "category", "name"], + "type": "object" + }, + "type": "array" + }, + "metrics": { + "additionalProperties": false, + "properties": { + "boolean": { + "additionalProperties": { + "type": "boolean" + }, + "propertyNames": { + "maxLength": 111, + "pattern": "^[a-z_][a-z0-9_\\.]+$", + "type": "string" + }, + "type": "object" + }, + "counter": { + "additionalProperties": { + "type": "integer" + }, + "propertyNames": { + "maxLength": 111, + "pattern": "^[a-z_][a-z0-9_\\.]+$", + "type": "string" + }, + "type": "object" + }, + "custom_distribution": { + "additionalProperties": { + "properties": { + "count": { + "description": "This was accidentally sent in the past and is now deprecated. See https://bugzilla.mozilla.org/show_bug.cgi?id=1799509#c5", + "type": "integer" + }, + "sum": { + "type": "integer" + }, + "values": { + "additionalProperties": { + "type": "integer" + }, + "propertyNames": { + "pattern": "^[0-9]+$" + }, + "type": "object" + } + }, + "required": ["sum", "values"], + "type": "object" + }, + "propertyNames": { + "maxLength": 111, + "pattern": "^[a-z_][a-z0-9_\\.]+$", + "type": "string" + }, + "type": "object" + }, + "datetime": { + "additionalProperties": { + "format": "datetime", + "type": "string" + }, + "propertyNames": { + "maxLength": 111, + "pattern": "^[a-z_][a-z0-9_\\.]+$", + "type": "string" + }, + "type": "object" + }, + "dual_labeled_counter": { + "additionalProperties": { + "additionalProperties": { + "additionalProperties": { + "type": "integer" + }, + "propertyNames": { + "comment": "This must be at least the length of 'category.name' metric names to support error reporting", + "maxLength": 111, + "type": "string" + }, + "type": "object" + }, + "propertyNames": { + "comment": "This must be at least the length of 'category.name' metric names to support error reporting", + "maxLength": 111, + "type": "string" + }, + "type": "object" + }, + "propertyNames": { + "maxLength": 111, + "pattern": "^[a-z_][a-z0-9_\\.]+$", + "type": "string" + }, + "type": "object" + }, + "jwe": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 111, + "pattern": "^[a-z_][a-z0-9_\\.]+$", + "type": "string" + }, + "type": "object" + }, + "labeled_boolean": { + "additionalProperties": { + "additionalProperties": { + "type": "boolean" + }, + "propertyNames": { + "comment": "This must be at least the length of 'category.name' metric names to support error reporting", + "maxLength": 111, + "type": "string" + }, + "type": "object" + }, + "propertyNames": { + "maxLength": 111, + "pattern": "^[a-z_][a-z0-9_\\.]+$", + "type": "string" + }, + "type": "object" + }, + "labeled_counter": { + "additionalProperties": { + "additionalProperties": { + "type": "integer" + }, + "propertyNames": { + "comment": "This must be at least the length of 'category.name' metric names to support error reporting", + "maxLength": 111, + "type": "string" + }, + "type": "object" + }, + "propertyNames": { + "maxLength": 111, + "pattern": "^[a-z_][a-z0-9_\\.]+$", + "type": "string" + }, + "type": "object" + }, + "labeled_custom_distribution": { + "additionalProperties": { + "additionalProperties": { + "properties": { + "count": { + "description": "This was accidentally sent in the past and is now deprecated. See https://bugzilla.mozilla.org/show_bug.cgi?id=1799509#c5", + "type": "integer" + }, + "sum": { + "type": "integer" + }, + "values": { + "additionalProperties": { + "type": "integer" + }, + "propertyNames": { + "pattern": "^[0-9]+$" + }, + "type": "object" + } + }, + "required": ["sum", "values"], + "type": "object" + }, + "propertyNames": { + "comment": "This must be at least the length of 'category.name' metric names to support error reporting", + "maxLength": 111, + "type": "string" + }, + "type": "object" + }, + "propertyNames": { + "maxLength": 111, + "pattern": "^[a-z_][a-z0-9_\\.]+$", + "type": "string" + }, + "type": "object" + }, + "labeled_memory_distribution": { + "additionalProperties": { + "additionalProperties": { + "properties": { + "count": { + "description": "This was accidentally sent in the past and is now deprecated. See https://bugzilla.mozilla.org/show_bug.cgi?id=1799509#c5", + "type": "integer" + }, + "sum": { + "type": "integer" + }, + "values": { + "additionalProperties": { + "type": "integer" + }, + "propertyNames": { + "pattern": "^[0-9]+$" + }, + "type": "object" + } + }, + "required": ["values"], + "type": "object" + }, + "propertyNames": { + "comment": "This must be at least the length of 'category.name' metric names to support error reporting", + "maxLength": 111, + "type": "string" + }, + "type": "object" + }, + "propertyNames": { + "maxLength": 111, + "pattern": "^[a-z_][a-z0-9_\\.]+$", + "type": "string" + }, + "type": "object" + }, + "labeled_quantity": { + "additionalProperties": { + "additionalProperties": { + "type": "integer" + }, + "propertyNames": { + "comment": "This must be at least the length of 'category.name' metric names to support error reporting", + "maxLength": 111, + "type": "string" + }, + "type": "object" + }, + "propertyNames": { + "maxLength": 111, + "pattern": "^[a-z_][a-z0-9_\\.]+$", + "type": "string" + }, + "type": "object" + }, + "labeled_rate": { + "additionalProperties": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "denominator": { + "minimum": 0, + "type": "integer" + }, + "numerator": { + "minimum": 0, + "type": "integer" + } + }, + "required": ["numerator", "denominator"], + "type": "object" + }, + "propertyNames": { + "comment": "This must be at least the length of 'category.name' metric names to support error reporting", + "maxLength": 111, + "type": "string" + }, + "type": "object" + }, + "propertyNames": { + "maxLength": 111, + "pattern": "^[a-z_][a-z0-9_\\.]+$", + "type": "string" + }, + "type": "object" + }, + "labeled_string": { + "additionalProperties": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "comment": "This must be at least the length of 'category.name' metric names to support error reporting", + "maxLength": 111, + "type": "string" + }, + "type": "object" + }, + "propertyNames": { + "maxLength": 111, + "pattern": "^[a-z_][a-z0-9_\\.]+$", + "type": "string" + }, + "type": "object" + }, + "labeled_timing_distribution": { + "additionalProperties": { + "additionalProperties": { + "properties": { + "bucket_count": { + "type": "integer" + }, + "count": { + "description": "This was accidentally sent in the past and is now deprecated. See https://bugzilla.mozilla.org/show_bug.cgi?id=1799509#c5", + "type": "integer" + }, + "histogram_type": { + "enum": ["linear", "exponential"], + "type": "string" + }, + "overflow": { + "type": "integer" + }, + "range": { + "items": { + "type": "number" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + "sum": { + "type": "integer" + }, + "time_unit": { + "enum": [ + "nanosecond", + "microsecond", + "millisecond", + "second", + "minute", + "hour", + "day" + ], + "type": "string" + }, + "underflow": { + "type": "integer" + }, + "values": { + "additionalProperties": { + "type": "integer" + }, + "propertyNames": { + "pattern": "^[0-9]+$" + }, + "type": "object" + } + }, + "required": ["values"], + "type": "object" + }, + "propertyNames": { + "comment": "This must be at least the length of 'category.name' metric names to support error reporting", + "maxLength": 111, + "type": "string" + }, + "type": "object" + }, + "propertyNames": { + "maxLength": 111, + "pattern": "^[a-z_][a-z0-9_\\.]+$", + "type": "string" + }, + "type": "object" + }, + "memory_distribution": { + "additionalProperties": { + "properties": { + "count": { + "description": "This was accidentally sent in the past and is now deprecated. See https://bugzilla.mozilla.org/show_bug.cgi?id=1799509#c5", + "type": "integer" + }, + "sum": { + "type": "integer" + }, + "values": { + "additionalProperties": { + "type": "integer" + }, + "propertyNames": { + "pattern": "^[0-9]+$" + }, + "type": "object" + } + }, + "required": ["values"], + "type": "object" + }, + "propertyNames": { + "maxLength": 111, + "pattern": "^[a-z_][a-z0-9_\\.]+$", + "type": "string" + }, + "type": "object" + }, + "object": { + "additionalProperties": { + "items": { + "format": "json" + }, + "type": ["object", "array"] + }, + "propertyNames": { + "maxLength": 111, + "pattern": "^[a-z_][a-z0-9_\\.]+$", + "type": "string" + }, + "type": "object" + }, + "quantity": { + "additionalProperties": { + "type": "integer" + }, + "propertyNames": { + "maxLength": 111, + "pattern": "^[a-z_][a-z0-9_\\.]+$", + "type": "string" + }, + "type": "object" + }, + "rate": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "denominator": { + "minimum": 0, + "type": "integer" + }, + "numerator": { + "minimum": 0, + "type": "integer" + } + }, + "required": ["numerator", "denominator"], + "type": "object" + }, + "propertyNames": { + "maxLength": 111, + "pattern": "^[a-z_][a-z0-9_\\.]+$", + "type": "string" + }, + "type": "object" + }, + "string": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 111, + "pattern": "^[a-z_][a-z0-9_\\.]+$", + "type": "string" + }, + "type": "object" + }, + "string_list": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "propertyNames": { + "maxLength": 111, + "pattern": "^[a-z_][a-z0-9_\\.]+$", + "type": "string" + }, + "type": "object" + }, + "text": { + "additionalProperties": { + "maxLength": 204800, + "type": "string" + }, + "propertyNames": { + "maxLength": 111, + "pattern": "^[a-z_][a-z0-9_\\.]+$", + "type": "string" + }, + "type": "object" + }, + "timespan": { + "additionalProperties": { + "properties": { + "time_unit": { + "enum": [ + "nanosecond", + "microsecond", + "millisecond", + "second", + "minute", + "hour", + "day" + ], + "type": "string" + }, + "value": { + "type": "integer" + } + }, + "required": ["value", "time_unit"], + "type": "object" + }, + "propertyNames": { + "maxLength": 111, + "pattern": "^[a-z_][a-z0-9_\\.]+$", + "type": "string" + }, + "type": "object" + }, + "timing_distribution": { + "additionalProperties": { + "properties": { + "bucket_count": { + "type": "integer" + }, + "count": { + "description": "This was accidentally sent in the past and is now deprecated. See https://bugzilla.mozilla.org/show_bug.cgi?id=1799509#c5", + "type": "integer" + }, + "histogram_type": { + "enum": ["linear", "exponential"], + "type": "string" + }, + "overflow": { + "type": "integer" + }, + "range": { + "items": { + "type": "number" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + "sum": { + "type": "integer" + }, + "time_unit": { + "enum": [ + "nanosecond", + "microsecond", + "millisecond", + "second", + "minute", + "hour", + "day" + ], + "type": "string" + }, + "underflow": { + "type": "integer" + }, + "values": { + "additionalProperties": { + "type": "integer" + }, + "propertyNames": { + "pattern": "^[0-9]+$" + }, + "type": "object" + } + }, + "required": ["values"], + "type": "object" + }, + "propertyNames": { + "maxLength": 111, + "pattern": "^[a-z_][a-z0-9_\\.]+$", + "type": "string" + }, + "type": "object" + }, + "url": { + "additionalProperties": { + "pattern": "^((?!data)([a-zA-Z][a-zA-Z0-9-\\+\\.]*):(.*))$", + "type": "string" + }, + "propertyNames": { + "maxLength": 111, + "pattern": "^[a-z_][a-z0-9_\\.]+$", + "type": "string" + }, + "type": "object" + }, + "uuid": { + "additionalProperties": { + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$", + "type": "string" + }, + "propertyNames": { + "maxLength": 111, + "pattern": "^[a-z_][a-z0-9_\\.]+$", + "type": "string" + }, + "type": "object" + } + }, + "type": "object" + }, + "ping_info": { + "additionalProperties": false, + "properties": { + "end_time": { + "format": "datetime", + "type": "string" + }, + "experiments": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "branch": { + "maxLength": 100, + "type": "string" + }, + "extra": { + "oneOf": [ + { + "type": "null" + }, + { + "properties": { + "enrollment_id": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "type": "object" + } + ] + } + }, + "required": ["branch"], + "type": "object" + }, + "propertyNames": { + "maxLength": 100, + "type": "string" + }, + "type": "object" + }, + "ping_type": { + "maxLength": 30, + "pattern": "^[a-z-_][a-z0-9-_]*$", + "type": "string" + }, + "reason": { + "maxLength": 30, + "type": "string" + }, + "seq": { + "type": "integer" + }, + "start_time": { + "format": "datetime", + "type": "string" + } + }, + "required": ["seq", "start_time", "end_time"], + "type": "object" + } + }, + "required": ["ping_info", "client_info"], + "title": "Ping transport", + "type": "object" +} diff --git a/tests/mcp.test.js b/tests/mcp.test.js index e13151731..d33aa3a48 100644 --- a/tests/mcp.test.js +++ b/tests/mcp.test.js @@ -95,6 +95,17 @@ describe("MCP Server request validation", () => { }); describe("MCP Server protocol methods", () => { + beforeEach(() => { + global.fetch.mockReset(); + // Default: accept telemetry POSTs silently + global.fetch.mockImplementation((url) => { + if (url.includes("/submit/")) { + return Promise.resolve({ ok: true, status: 200 }); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + }); + it("handles initialize request", async () => { const response = await callHandler("POST", { jsonrpc: "2.0", @@ -146,8 +157,11 @@ describe("MCP Server tool calls", () => { }); it("handles tools/call for list_apps", async () => { - // Mock the API responses + // Mock the API responses (including telemetry POST) global.fetch.mockImplementation((url) => { + if (url.includes("/submit/")) { + return Promise.resolve({ ok: true, status: 200 }); + } if (url.includes("app-listings")) { return Promise.resolve({ ok: true, @@ -191,14 +205,17 @@ describe("MCP Server tool calls", () => { }); it("handles tool call errors gracefully", async () => { - // Mock a failing API - global.fetch.mockImplementation(() => - Promise.resolve({ + // Mock a failing API (but telemetry POST still succeeds) + global.fetch.mockImplementation((url) => { + if (url.includes("/submit/")) { + return Promise.resolve({ ok: true, status: 200 }); + } + return Promise.resolve({ ok: false, status: 500, statusText: "Internal Server Error", - }) - ); + }); + }); const response = await callHandler("POST", { jsonrpc: "2.0", @@ -217,6 +234,13 @@ describe("MCP Server tool calls", () => { }); it("handles unknown tool name", async () => { + global.fetch.mockImplementation((url) => { + if (url.includes("/submit/")) { + return Promise.resolve({ ok: true, status: 200 }); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + const response = await callHandler("POST", { jsonrpc: "2.0", id: 12, @@ -232,3 +256,228 @@ describe("MCP Server tool calls", () => { expect(errorData.error).toContain("Unknown tool"); }); }); + +describe("MCP Server telemetry", () => { + beforeEach(() => { + global.fetch.mockReset(); + }); + + function mockWithTelemetryCapture() { + const telemetryCalls = []; + global.fetch.mockImplementation((url, options) => { + if (url.includes("/submit/")) { + telemetryCalls.push({ url, options }); + return Promise.resolve({ ok: true, status: 200 }); + } + if (url.includes("app-listings")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve([ + { + app_name: "test_app", + app_description: "Test application", + canonical_app_name: "Test App", + deprecated: false, + url: "https://example.com", + v1_name: "test-app", + }, + ]), + }); + } + if (url.includes("annotations")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + }); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + return telemetryCalls; + } + + it("sends telemetry on successful tool call", async () => { + const telemetryCalls = mockWithTelemetryCapture(); + + await callHandler("POST", { + jsonrpc: "2.0", + id: 20, + method: "tools/call", + params: { name: "list_apps", arguments: {} }, + }); + + expect(telemetryCalls).toHaveLength(1); + const { url, options } = telemetryCalls[0]; + expect(url).toMatch(/\/submit\/.*\/events\/1\//); + + const body = JSON.parse(options.body); + expect(body.ping_info).toBeDefined(); + expect(body.client_info).toBeDefined(); + expect(body.events).toHaveLength(1); + + const event = body.events[0]; + expect(event.category).toBe("mcp"); + expect(event.name).toBe("tool_call"); + expect(event.extra.tool_name).toBe("list_apps"); + expect(event.extra.success).toBe("true"); + }); + + it("sends telemetry with success=false on tool error", async () => { + const telemetryCalls = []; + global.fetch.mockImplementation((url, options) => { + if (url.includes("/submit/")) { + telemetryCalls.push({ url, options }); + return Promise.resolve({ ok: true, status: 200 }); + } + return Promise.resolve({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }); + }); + + const response = await callHandler("POST", { + jsonrpc: "2.0", + id: 21, + method: "tools/call", + params: { name: "list_apps", arguments: {} }, + }); + + expect(response.parsedBody.result.isError).toBe(true); + expect(telemetryCalls).toHaveLength(1); + + const body = JSON.parse(telemetryCalls[0].options.body); + expect(body.events[0].extra.success).toBe("false"); + }); + + it("sends telemetry on initialize", async () => { + const telemetryCalls = []; + global.fetch.mockImplementation((url, options) => { + if (url.includes("/submit/")) { + telemetryCalls.push({ url, options }); + return Promise.resolve({ ok: true, status: 200 }); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + + await callHandler("POST", { + jsonrpc: "2.0", + id: 22, + method: "initialize", + params: { + clientInfo: { name: "test-client", version: "1.2.3" }, + }, + }); + + expect(telemetryCalls).toHaveLength(1); + const body = JSON.parse(telemetryCalls[0].options.body); + expect(body.events[0].category).toBe("mcp"); + expect(body.events[0].name).toBe("initialize"); + expect(body.events[0].extra.client_name).toBe("test-client"); + expect(body.events[0].extra.client_version).toBe("1.2.3"); + }); + + it("telemetry failure does not break MCP response", async () => { + global.fetch.mockImplementation((url) => { + if (url.includes("/submit/")) { + return Promise.reject(new Error("Telemetry network error")); + } + if (url.includes("app-listings")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve([ + { + app_name: "test_app", + app_description: "Test", + canonical_app_name: "Test", + deprecated: false, + url: "https://example.com", + v1_name: "test-app", + }, + ]), + }); + } + if (url.includes("annotations")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + }); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + + const response = await callHandler("POST", { + jsonrpc: "2.0", + id: 23, + method: "tools/call", + params: { name: "list_apps", arguments: {} }, + }); + + // MCP response should succeed even though telemetry failed + expect(response.statusCode).toBe(200); + expect(response.parsedBody.result.isError).toBeUndefined(); + const resultData = JSON.parse(response.parsedBody.result.content[0].text); + expect(resultData[0].app_name).toBe("test_app"); + }); + + it("includes app_name in telemetry when provided", async () => { + const telemetryCalls = mockWithTelemetryCapture(); + + // get_app requires fetching metrics, pings, tags — mock them all + global.fetch.mockImplementation((url, options) => { + if (url.includes("/submit/")) { + telemetryCalls.push({ url, options }); + return Promise.resolve({ ok: true, status: 200 }); + } + if (url.includes("app-listings")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve([ + { + app_name: "test_app", + app_description: "Test", + canonical_app_name: "Test", + deprecated: false, + url: "https://example.com", + v1_name: "test-app", + app_id: "org.test", + notification_emails: ["test@example.com"], + }, + ]), + }); + } + if (url.includes("annotations")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + }); + } + if ( + url.includes("metrics") || + url.includes("pings") || + url.includes("tags") + ) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + }); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + + await callHandler("POST", { + jsonrpc: "2.0", + id: 24, + method: "tools/call", + params: { name: "get_app", arguments: { app_name: "test_app" } }, + }); + + expect(telemetryCalls.length).toBeGreaterThanOrEqual(1); + const body = JSON.parse( + telemetryCalls[telemetryCalls.length - 1].options.body + ); + expect(body.events[0].extra.app_name).toBe("test_app"); + }); +}); diff --git a/tests/telemetry.test.js b/tests/telemetry.test.js new file mode 100644 index 000000000..3937cfcf1 --- /dev/null +++ b/tests/telemetry.test.js @@ -0,0 +1,281 @@ +/** + * Tests for the telemetry module — validates ping payloads against the + * official Glean ping schema (glean.1.schema.json). + */ + +const fs = require("fs"); +const path = require("path"); +const Ajv = require("ajv"); +const draft06MetaSchema = require("ajv/dist/refs/json-schema-draft-06.json"); +const nodeFetch = require("node-fetch"); + +// Mock global fetch before requiring the module +global.fetch = jest.fn(() => + Promise.resolve({ ok: true, status: 200, statusText: "OK" }) +); + +const { submitTelemetryEvent, buildPing } = require("../.netlify/telemetry"); +const gleanSchema = require("./fixtures/glean.1.schema.json"); + +// Set up AJV with draft-06 support (strict: false for custom keywords like mozPipelineMetadata) +const ajv = new Ajv({ allErrors: true, strict: false }); +ajv.addMetaSchema(draft06MetaSchema); +const validate = ajv.compile(gleanSchema); + +describe("Glean ping schema validation", () => { + it("produces a valid ping with all extras", () => { + const ping = buildPing([ + { + category: "mcp", + name: "tool_call", + extra: { + tool_name: "get_app", + app_name: "fenix", + success: "true", + client_name: "claude-code", + }, + }, + ]); + + const valid = validate(ping); + expect(validate.errors).toBeNull(); + expect(valid).toBe(true); + }); + + it("produces a valid ping with minimal extras", () => { + const ping = buildPing([ + { + category: "mcp", + name: "tool_call", + extra: { tool_name: "list_apps", success: "true" }, + }, + ]); + + const valid = validate(ping); + expect(validate.errors).toBeNull(); + expect(valid).toBe(true); + }); + + it("produces a valid ping with no extras", () => { + const ping = buildPing([ + { + category: "mcp", + name: "initialize", + extra: {}, + }, + ]); + + const valid = validate(ping); + expect(validate.errors).toBeNull(); + expect(valid).toBe(true); + }); + + it("includes required client_info fields", () => { + const ping = buildPing([ + { category: "mcp", name: "tool_call", extra: { tool_name: "get_app" } }, + ]); + + const requiredFields = [ + "app_build", + "app_display_version", + "architecture", + "first_run_date", + "os", + "os_version", + "telemetry_sdk_build", + ]; + + requiredFields.forEach((field) => { + expect(ping.client_info).toHaveProperty(field); + expect(typeof ping.client_info[field]).toBe("string"); + }); + }); + + it("includes required ping_info fields", () => { + const ping = buildPing([ + { category: "mcp", name: "tool_call", extra: { tool_name: "get_app" } }, + ]); + + expect(ping.ping_info).toHaveProperty("seq"); + expect(typeof ping.ping_info.seq).toBe("number"); + expect(ping.ping_info).toHaveProperty("start_time"); + expect(ping.ping_info).toHaveProperty("end_time"); + }); + + it("event structure matches expected shape", () => { + const ping = buildPing([ + { + category: "mcp", + name: "tool_call", + extra: { tool_name: "search_metrics", success: "false" }, + }, + ]); + + expect(ping.events).toHaveLength(1); + const event = ping.events[0]; + expect(event.category).toBe("mcp"); + expect(event.name).toBe("tool_call"); + expect(event.timestamp).toBe(0); + expect(event.extra.tool_name).toBe("search_metrics"); + expect(event.extra.success).toBe("false"); + }); +}); + +describe("submitTelemetryEvent", () => { + beforeEach(() => { + global.fetch.mockClear(); + // Reset env vars + delete process.env.GLEAN_APPLICATION_ID; + delete process.env.GLEAN_INGESTION_URL; + delete process.env.GLEAN_DEBUG_VIEW_TAG; + delete process.env.CONTEXT; + }); + + it("POSTs to the correct ingestion URL", async () => { + await submitTelemetryEvent("mcp", "tool_call", { + tool_name: "list_apps", + success: "true", + }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + const [url, options] = global.fetch.mock.calls[0]; + expect(url).toMatch( + /^https:\/\/incoming\.telemetry\.mozilla\.org\/submit\/glean-dictionary-dev\/events\/1\// + ); + expect(options.method).toBe("POST"); + expect(options.headers["Content-Type"]).toBe( + "application/json; charset=utf-8" + ); + expect(options.headers["X-Telemetry-Agent"]).toBe("Glean/handcrafted"); + }); + + it("uses custom app ID from environment", async () => { + process.env.GLEAN_APPLICATION_ID = "glean-dictionary"; + await submitTelemetryEvent("mcp", "tool_call", { + tool_name: "get_app", + success: "true", + }); + + const [url] = global.fetch.mock.calls[0]; + expect(url).toMatch(/\/submit\/glean-dictionary\/events\/1\//); + }); + + it("uses custom ingestion URL from environment", async () => { + process.env.GLEAN_INGESTION_URL = "https://custom.endpoint.example"; + await submitTelemetryEvent("mcp", "tool_call", { + tool_name: "get_app", + success: "true", + }); + + const [url] = global.fetch.mock.calls[0]; + expect(url).toMatch(/^https:\/\/custom\.endpoint\.example\/submit\//); + }); + + it("adds X-Debug-ID header when GLEAN_DEBUG_VIEW_TAG is set", async () => { + process.env.GLEAN_DEBUG_VIEW_TAG = "mcp-test"; + await submitTelemetryEvent("mcp", "tool_call", { + tool_name: "list_apps", + success: "true", + }); + + const [, options] = global.fetch.mock.calls[0]; + expect(options.headers["X-Debug-ID"]).toBe("mcp-test"); + }); + + it("does not add X-Debug-ID header when GLEAN_DEBUG_VIEW_TAG is not set", async () => { + await submitTelemetryEvent("mcp", "tool_call", { + tool_name: "list_apps", + success: "true", + }); + + const [, options] = global.fetch.mock.calls[0]; + expect(options.headers["X-Debug-ID"]).toBeUndefined(); + }); + + it("filters out null/undefined extra values", async () => { + await submitTelemetryEvent("mcp", "tool_call", { + tool_name: "list_apps", + app_name: null, + client_name: undefined, + success: "true", + }); + + const [, options] = global.fetch.mock.calls[0]; + const body = JSON.parse(options.body); + expect(body.events[0].extra).toEqual({ + tool_name: "list_apps", + success: "true", + }); + }); + + it("never throws even when fetch fails", async () => { + global.fetch.mockRejectedValueOnce(new Error("Network error")); + + // Should not throw + await expect( + submitTelemetryEvent("mcp", "tool_call", { + tool_name: "list_apps", + success: "true", + }) + ).resolves.toBeUndefined(); + }); + + it("sends a schema-valid payload", async () => { + await submitTelemetryEvent("mcp", "tool_call", { + tool_name: "get_metric", + app_name: "firefox_desktop", + success: "true", + client_name: "claude-code", + }); + + const [, options] = global.fetch.mock.calls[0]; + const body = JSON.parse(options.body); + const valid = validate(body); + expect(validate.errors).toBeNull(); + expect(valid).toBe(true); + }); + + it("maps CONTEXT=production to app_channel production", async () => { + process.env.CONTEXT = "production"; + await submitTelemetryEvent("mcp", "initialize", {}); + + const [, options] = global.fetch.mock.calls[0]; + const body = JSON.parse(options.body); + expect(body.client_info.app_channel).toBe("production"); + }); + + it("maps CONTEXT=deploy-preview to app_channel deploy-preview", async () => { + process.env.CONTEXT = "deploy-preview"; + await submitTelemetryEvent("mcp", "initialize", {}); + + const [, options] = global.fetch.mock.calls[0]; + const body = JSON.parse(options.body); + expect(body.client_info.app_channel).toBe("deploy-preview"); + }); + + it("defaults app_channel to development when CONTEXT is unset", async () => { + await submitTelemetryEvent("mcp", "initialize", {}); + + const [, options] = global.fetch.mock.calls[0]; + const body = JSON.parse(options.body); + expect(body.client_info.app_channel).toBe("development"); + }); +}); + +describe("Glean schema freshness", () => { + const SCHEMA_URL = + "https://raw.githubusercontent.com/mozilla-services/mozilla-pipeline-schemas/generated-schemas/schemas/glean/glean/glean.1.schema.json"; + const LOCAL_SCHEMA_PATH = path.resolve( + __dirname, + "fixtures/glean.1.schema.json" + ); + + it("local schema matches upstream mozilla-pipeline-schemas", async () => { + const response = await nodeFetch(SCHEMA_URL); + expect(response.ok).toBe(true); + const upstream = await response.json(); + + const local = JSON.parse(fs.readFileSync(LOCAL_SCHEMA_PATH, "utf-8")); + expect(local).toEqual(upstream); + }); +});