From 1bce9025896a4ba92cba19b932cd434a21a93431 Mon Sep 17 00:00:00 2001 From: Matias Fernandez <7898112+MatiasFernandez@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:37:36 -0300 Subject: [PATCH 1/4] Let the fake telemetry logger hand events to a real sender The fake createTelemetryLogger ignored its sender and recorded every event to __telemetry. A second sink needs its sender exercised, so when one is passed the fake now calls sendEventData the way VS Code does: the name prefixed with the extension id, and the common properties mixed into data.properties, or into the top level when there is none. The TelemetryReporter mock passes no sender, so __telemetry still records exactly what App Insights is sent and no suite changes. Like VS Code, each fake logger adds only the additionalCommonProperties it was created with, and common properties win over an event's own on a collision. So a sink missing a property only another logger adds fails a test instead of passing. Co-Authored-By: Claude Opus 5.5 --- __mocks__/@vscode/extension-telemetry.ts | 2 +- src/__mocks__/vscode.ts | 42 ++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/__mocks__/@vscode/extension-telemetry.ts b/__mocks__/@vscode/extension-telemetry.ts index e1f9f8e..77b5acc 100644 --- a/__mocks__/@vscode/extension-telemetry.ts +++ b/__mocks__/@vscode/extension-telemetry.ts @@ -17,7 +17,7 @@ export class TelemetryReporter { private readonly logger: ReturnType; constructor(_connectionString: string) { - // The mock's `createTelemetryLogger` ignores the sender entirely. + // No sender, so the mock's `createTelemetryLogger` records to `__telemetry`. this.logger = vscode.env.createTelemetryLogger(undefined as never); } diff --git a/src/__mocks__/vscode.ts b/src/__mocks__/vscode.ts index 5e216fe..8d047f5 100644 --- a/src/__mocks__/vscode.ts +++ b/src/__mocks__/vscode.ts @@ -106,9 +106,35 @@ export const ExtensionMode = { Production: 1, Development: 2, Test: 3 } as const * common property, so a call that forgets to pass a properties object (see * `telemetry.ts`'s own warning against ever doing that) is regression-tested * rather than only commented on. + * + * Given a real sender — one with `sendEventData` — it hands every event to + * it the way VS Code does: the name prefixed with the extension id, and the + * common properties mixed in by the same rule, plus any + * `additionalCommonProperties` that logger was created with. Only that + * logger gets those, as in VS Code, so a sink that needs a property only + * another logger adds is caught. Common properties win over the event's own + * when a key collides, as VS Code's `mixin` does. Those events never reach + * `__telemetry`, which stays the record of what the `TelemetryReporter` mock + * (which passes no sender) sent, so a second sink is tested through the + * sender's own effects without doubling every event recorded there. */ const FAKE_COMMON_PROPERTIES: Record = { 'common.fake': 'yes' }; +/** What VS Code puts in front of every event name a sender receives: `./`. */ +export const FAKE_EVENT_NAME_PREFIX = 'gemtalksystems.gemdb/'; + +interface FakeTelemetrySender { + sendEventData(eventName: string, data?: Record): void; +} + +function isTelemetrySender(sender: unknown): sender is FakeTelemetrySender { + return ( + typeof sender === 'object' && + sender !== null && + typeof (sender as Partial).sendEventData === 'function' + ); +} + /** Two-line insurance: nothing here reads `env` today, but `activate()` does. */ export const env = { machineId: 'fake-machine-id', @@ -123,8 +149,8 @@ export const env = { }, uiKind: UIKind.Desktop, createTelemetryLogger( - _sender: unknown, - _options?: unknown, + sender: unknown, + options?: { additionalCommonProperties?: Record }, ): { isUsageEnabled: boolean; isErrorsEnabled: boolean; @@ -142,9 +168,19 @@ export const env = { onDidChangeEnableStates: () => new Disposable(() => {}), logUsage: (name, data) => { const properties = data?.properties; + const common = { ...options?.additionalCommonProperties, ...FAKE_COMMON_PROPERTIES }; + if (isTelemetrySender(sender)) { + sender.sendEventData( + `${FAKE_EVENT_NAME_PREFIX}${name}`, + properties + ? { ...data, properties: { ...properties, ...common } } + : { ...data, ...common }, + ); + return; + } __telemetry.push({ name, - properties: properties ? { ...FAKE_COMMON_PROPERTIES, ...properties } : {}, + properties: properties ? { ...properties, ...common } : {}, measurements: data?.measurements, }); }, From 9aec6e127b4121b90eaac1f395a8be8c84e48384 Mon Sep 17 00:00:00 2001 From: Matias Fernandez <7898112+MatiasFernandez@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:38:37 -0300 Subject: [PATCH 2/4] Build Scarf request URLs and send them, not yet wired scarfUrl turns one event into a GET on the Scarf gateway: event first, then every GemDB property and measure as a query parameter, plus an allowlist of five common.* properties. A property VS Code adds later never reaches Scarf unreviewed, while a new GemDB property needs no change here. ScarfSender is the TelemetrySender that builds it. VS Code prefixes every event name with the extension id before a sender sees it, so the sender strips that and Scarf's event reads as EVENT spells it. The request itself lives in scarfTransport.ts: fire-and-forget, three-second timeout, every error swallowed, including one https.get throws before a request exists, since VS Code does not catch what a sender throws. It is a module of its own so setup.ts can swap it for a recorder in every test file. The suites that call initTelemetry use a Production context, the mode Scarf will be sent from, and must never reach the real gateway. Only scarfTransport.test.ts asks for the real one, with https mocked. It sends with no consent check of its own, so both shell-bundle guards (the eslint import restriction and the esbuild metafile check) now cover it as they cover telemetry.ts. Nothing calls it yet. Co-Authored-By: Claude Opus 5.5 --- esbuild.mjs | 22 +++++---- eslint.config.mjs | 8 +++ src/__mocks__/scarfTransport.ts | 17 +++++++ src/__mocks__/setup.ts | 9 ++++ src/__tests__/scarfTelemetry.test.ts | 73 ++++++++++++++++++++++++++++ src/__tests__/scarfTransport.test.ts | 65 +++++++++++++++++++++++++ src/scarfTransport.ts | 34 +++++++++++++ src/telemetry.ts | 72 ++++++++++++++++++++++++--- 8 files changed, 284 insertions(+), 16 deletions(-) create mode 100644 src/__mocks__/scarfTransport.ts create mode 100644 src/__tests__/scarfTelemetry.test.ts create mode 100644 src/__tests__/scarfTransport.test.ts create mode 100644 src/scarfTransport.ts diff --git a/esbuild.mjs b/esbuild.mjs index c963d38..22d921c 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -89,9 +89,11 @@ const builds = [ ]; /** - * Neither telemetry.ts nor the `@vscode/extension-telemetry` package it wraps - * may reach the shell bundle: there is no extension host there to enforce the - * user's telemetry setting (see telemetry.ts). ESLint's `no-restricted-imports` + * Neither telemetry.ts, scarfTransport.ts, nor the `@vscode/extension-telemetry` + * package may reach the shell bundle: there is no extension host there to + * enforce the user's telemetry setting (see telemetry.ts). scarfTransport.ts + * sends to Scarf with no consent check of its own; the logger telemetry.ts + * wraps it in is what honours the setting. ESLint's `no-restricted-imports` * catches a direct import of telemetry.ts, but not a rename, a re-export, or a * facade module — checking the graph esbuild actually built catches all of * those. The package check is the ultimate guard: it also catches a file that @@ -103,13 +105,15 @@ const builds = [ function assertNoTelemetryInShellBundle(result) { if (!result.metafile) return; const inputs = Object.keys(result.metafile.inputs); - const reachedOwnModule = inputs.some((input) => input.endsWith('src/telemetry.ts')); - const reachedPackage = inputs.some((input) => - input.includes('node_modules/@vscode/extension-telemetry/'), - ); - if (reachedOwnModule || reachedPackage) { + const reached = [ + inputs.some((input) => input.endsWith('src/telemetry.ts')) && 'src/telemetry.ts', + inputs.some((input) => input.endsWith('src/scarfTransport.ts')) && 'src/scarfTransport.ts', + inputs.some((input) => input.includes('node_modules/@vscode/extension-telemetry/')) && + '@vscode/extension-telemetry', + ].find(Boolean); + if (reached) { throw new BuildAssertionError( - `out/gemdb-shell.js pulled in ${reachedOwnModule ? 'src/telemetry.ts' : '@vscode/extension-telemetry'}. ` + + `out/gemdb-shell.js pulled in ${reached}. ` + "There is no extension host in the shell to enforce the user's telemetry setting — " + 'this must never ship.', ); diff --git a/eslint.config.mjs b/eslint.config.mjs index e00f4a0..4e20dd9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -99,6 +99,14 @@ export default tseslint.config( 'a report* function in src/telemetry.ts and call it from a file ' + 'outside the shell graph (extension.ts, lifecycle.ts, ...).', }, + { + group: ['**/scarfTransport'], + message: + 'This file reaches out/gemdb-shell.js, where there is no extension ' + + "host to enforce the user's telemetry setting, and scarfTransport " + + 'sends to Scarf with no consent check of its own. Only ' + + 'src/telemetry.ts may use it.', + }, ], }, ], diff --git a/src/__mocks__/scarfTransport.ts b/src/__mocks__/scarfTransport.ts new file mode 100644 index 0000000..5baac62 --- /dev/null +++ b/src/__mocks__/scarfTransport.ts @@ -0,0 +1,17 @@ +/** + * Stands in for `src/scarfTransport.ts` in every test file, unit and integration, via + * `src/__mocks__/setup.ts`, so no test can send a request to Scarf's real + * gateway — `fakeExtensionContext` is a Production context, which is the + * mode Scarf is sent from. Records each URL instead, for the suites that + * assert on what Scarf would receive. + */ +import type { ScarfTransport } from '../scarfTransport'; + +/** Every URL sent to Scarf, in order. */ +export const __scarfRequests: string[] = []; + +export const scarfTransport: ScarfTransport = { + get(url) { + __scarfRequests.push(url); + }, +}; diff --git a/src/__mocks__/setup.ts b/src/__mocks__/setup.ts index 245d01e..aa87844 100644 --- a/src/__mocks__/setup.ts +++ b/src/__mocks__/setup.ts @@ -9,3 +9,12 @@ import { vi } from 'vitest'; // test file, since which files happen to reach telemetry.ts is an // implementation detail, not something each test should have to know. vi.mock('@vscode/extension-telemetry'); + +// Replaces the one module that sends to Scarf with a recorder, in both +// suites. Unlike the line above, this one is about suppressing events: +// the suites that call `initTelemetry` pass a Production context, and +// without it each would send a real request to Scarf. Mocking this module +// rather than `https` keeps the rest of Node's network available to a test +// that needs it; getting the real transport takes a deliberate +// `vi.importActual`, as `scarfTransport.test.ts` does. +vi.mock('../scarfTransport'); diff --git a/src/__tests__/scarfTelemetry.test.ts b/src/__tests__/scarfTelemetry.test.ts new file mode 100644 index 0000000..2fbd8ae --- /dev/null +++ b/src/__tests__/scarfTelemetry.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { FAKE_EVENT_NAME_PREFIX } from '../__mocks__/vscode'; +import { ScarfSender, scarfUrl } from '../telemetry'; + +/** The query parameters of a Scarf request, decoded. */ +function paramsOf(url: string): Record { + return Object.fromEntries(new URL(url).searchParams); +} + +describe('scarfUrl', () => { + it('sends GemDB properties and measures, and the allowlisted common properties', () => { + const url = scarfUrl('databaseStarted', { + trigger: 'notebook', + durationMs: 1234, + 'common.vscodemachineid': 'machine', + 'common.extversion': '1.6.0', + 'common.os': 'darwin', + 'common.vscodeversion': '1.105.0', + 'common.remotename': 'ssh-remote', + }); + + expect(url.startsWith('https://gemdb.gateway.scarf.sh/gemdb/code/telemetry?')).toBe(true); + expect(paramsOf(url)).toEqual({ + event: 'databaseStarted', + trigger: 'notebook', + durationMs: '1234', + 'common.vscodemachineid': 'machine', + 'common.extversion': '1.6.0', + 'common.os': 'darwin', + 'common.vscodeversion': '1.105.0', + 'common.remotename': 'ssh-remote', + }); + }); + + it('puts the event first', () => { + const url = scarfUrl('activated', { state: 'running' }); + + expect(new URL(url).search).toBe('?event=activated&state=running'); + }); + + it('drops every other common property', () => { + const params = paramsOf( + scarfUrl('activated', { 'common.vscodesessionid': 'session', 'common.uikind': 'desktop' }), + ); + + expect(params).toEqual({ event: 'activated' }); + }); + + it('drops undefined values', () => { + const params = paramsOf(scarfUrl('activated', { 'common.remotename': undefined })); + + expect(params).toEqual({ event: 'activated' }); + }); + + it('encodes values', () => { + const url = scarfUrl('activated', { appName: 'Visual Studio Code', note: 'a&b=c' }); + + expect(url).toContain('appName=Visual%20Studio%20Code'); + expect(paramsOf(url)).toMatchObject({ appName: 'Visual Studio Code', note: 'a&b=c' }); + }); +}); + +describe('ScarfSender', () => { + it('sends each event once, named without the extension id VS Code adds', () => { + const urls: string[] = []; + const sender = new ScarfSender({ get: (url) => urls.push(url) }); + + sender.sendEventData(`${FAKE_EVENT_NAME_PREFIX}activated`, { state: 'running' }); + + expect(urls).toHaveLength(1); + expect(paramsOf(urls[0])).toEqual({ event: 'activated', state: 'running' }); + }); +}); diff --git a/src/__tests__/scarfTransport.test.ts b/src/__tests__/scarfTransport.test.ts new file mode 100644 index 0000000..015425d --- /dev/null +++ b/src/__tests__/scarfTransport.test.ts @@ -0,0 +1,65 @@ +import { EventEmitter } from 'events'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +/** A request as `https.get` returns one: something to listen on and destroy. */ +class FakeRequest extends EventEmitter { + destroy = vi.fn(); +} + +const requests = vi.hoisted(() => ({ + urls: [] as string[], + last: undefined as unknown, + throwOnGet: undefined as Error | undefined, +})); +vi.mock('https', () => ({ + get: (url: string) => { + if (requests.throwOnGet) throw requests.throwOnGet; + requests.urls.push(url); + const request = new FakeRequest(); + requests.last = request; + return request; + }, +})); + +// setup.ts replaces this module with a recorder for every test file; this is +// the one suite that exercises the real one, with `https` mocked above. +const { scarfTransport } = + await vi.importActual('../scarfTransport'); + +const EVENT_URL = 'https://gemdb.gateway.scarf.sh/gemdb/code/telemetry?event=activated'; + +beforeEach(() => { + requests.urls.length = 0; + requests.last = undefined; + requests.throwOnGet = undefined; +}); + +describe('the Scarf transport', () => { + it('issues one GET for each event', () => { + scarfTransport.get(EVENT_URL); + + expect(requests.urls).toEqual([EVENT_URL]); + }); + + it('does not throw when the request fails', () => { + scarfTransport.get(EVENT_URL); + const request = requests.last as FakeRequest; + + expect(() => request.emit('error', new Error('ECONNREFUSED'))).not.toThrow(); + }); + + it('does not throw when a request cannot even be made', () => { + requests.throwOnGet = new TypeError('Invalid proxy URL'); + + expect(() => scarfTransport.get(EVENT_URL)).not.toThrow(); + }); + + it('gives up on a request that times out', () => { + scarfTransport.get(EVENT_URL); + const request = requests.last as FakeRequest; + + request.emit('timeout'); + + expect(request.destroy).toHaveBeenCalled(); + }); +}); diff --git a/src/scarfTransport.ts b/src/scarfTransport.ts new file mode 100644 index 0000000..76636fb --- /dev/null +++ b/src/scarfTransport.ts @@ -0,0 +1,34 @@ +import * as https from 'https'; + +const TIMEOUT_MS = 3000; + +/** Where a Scarf request leaves the process. */ +export interface ScarfTransport { + get(url: string): void; +} + +/** + * The real network, kept to a module of its own so no test can reach it: + * `src/__mocks__/setup.ts` swaps this module for a recorder in every test + * file, and only `scarfTransport.test.ts` asks for the real one. + * + * Fire-and-forget: no retry, no queue, and nothing here can throw into the + * caller. A lost event is cheaper than a slow or failing activation. + * `https.get` rather than `fetch` because VS Code's proxy support patches + * Node's `https` on every version GemDB supports. + */ +export const scarfTransport: ScarfTransport = { + get(url) { + try { + const request = https.get(url, { timeout: TIMEOUT_MS }, (res) => res.resume()); + request.on('timeout', () => request.destroy()); + request.on('error', () => { + /* fire-and-forget: a lost event is not worth a retry or a message */ + }); + } catch { + // `https.get` can also throw before a request exists — a proxy agent + // rejecting a malformed setting, say — and VS Code does not catch what + // a sender throws, so it would reach `activate()`. + } + }, +}; diff --git a/src/telemetry.ts b/src/telemetry.ts index 3b95140..936625e 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { TelemetryReporter } from '@vscode/extension-telemetry'; import type { DiskSnapshot } from './paths'; +import type { ScarfTransport } from './scarfTransport'; import type { GemDbState } from './statusView'; /** @@ -10,13 +11,14 @@ import type { GemDbState } from './statusView'; * * Four rules a future edit can break, none visible from a call site: * - * 1. **This module must never reach `out/gemdb-shell.js`** — no extension host - * there enforces the user's telemetry setting (CLAUDE.md, "`gemdb` with no - * arguments IS the GemDB Shell"). `no-restricted-imports` in - * `eslint.config.mjs` denies it to every shell-graph file, `session.ts` and - * `pythonQueries.ts` permanently (they get an injected sink instead); - * `assertNoTelemetryInShellBundle` in `esbuild.mjs` checks the built graph - * and is the guard that actually holds. + * 1. **This module, and `scarfTransport.ts` with it, must never reach + * `out/gemdb-shell.js`** — no extension host there enforces the user's + * telemetry setting (CLAUDE.md, "`gemdb` with no arguments IS the GemDB + * Shell"). `no-restricted-imports` in `eslint.config.mjs` denies both to + * every shell-graph file, `session.ts` and `pythonQueries.ts` permanently + * (they get an injected sink instead); `assertNoTelemetryInShellBundle` in + * `esbuild.mjs` checks the built graph and is the guard that actually + * holds. * 2. **Never `sendDangerousTelemetryEvent`** or its siblings: they bypass the * user's setting by design, and shipping one violates Marketplace policy. * 3. **Events are facts, not funnels: no `first*` events.** First-ness is @@ -59,6 +61,62 @@ const CONNECTION_STRING = 'LiveEndpoint=https://westus2.livediagnostics.monitor.azure.com/;' + 'ApplicationId=0459d5a4-2436-4702-9979-c274a9da9b37'; +// Not a secret either — anyone can send to a Scarf gateway; it only says +// where events land. +const SCARF_ENDPOINT = 'https://gemdb.gateway.scarf.sh/gemdb/code/telemetry'; + +/** + * The `common.*` properties Scarf is sent; VS Code's others stay with App + * Insights. An allowlist rather than a denylist, so a property VS Code adds + * later never reaches Scarf unreviewed. GemDB's own properties and measures + * are not listed: they all go, so a new one needs no change here. + */ +const SCARF_COMMON_FIELDS = new Set([ + 'common.vscodemachineid', + 'common.extversion', + 'common.os', + 'common.vscodeversion', + 'common.remotename', +]); + +/** + * The request that reports one event to Scarf: `event` first, then every + * field as a query parameter — so the gateway needs no path template, and + * one that matches every event never falls behind `EVENT`. Every value is + * sent as a string, measures included. + */ +export function scarfUrl(eventName: string, data: Record): string { + const params = [`event=${encodeURIComponent(eventName)}`]; + for (const [key, value] of Object.entries(data)) { + if (value === undefined) continue; + if (key.startsWith('common.') && !SCARF_COMMON_FIELDS.has(key)) continue; + params.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + } + return `${SCARF_ENDPOINT}?${params.join('&')}`; +} + +/** + * Reports each event to Scarf through `transport`, which owns the network + * and never throws. + * + * VS Code prefixes every event name with the extension id + * (`gemtalksystems.gemdb/activated`) before a sender sees it; that is + * stripped, so Scarf's `event` reads as `EVENT` spells it. + * + * Exported only so tests can drive it, as `EVENT` is. + */ +export class ScarfSender implements vscode.TelemetrySender { + constructor(private readonly transport: ScarfTransport) {} + + sendEventData(eventName: string, data?: Record): void { + const name = eventName.slice(eventName.indexOf('/') + 1); + this.transport.get(scarfUrl(name, data ?? {})); + } + + /** GemDB sends no error telemetry. */ + sendErrorData(): void {} +} + /** * Every event GemDB can emit. `send` is typed to it, so a name cannot be * typo'd, and `send` is private, so no event can exist without a `report*` From 0a007e87698c97cd87048f8a4da2c4737644c912 Mon Sep 17 00:00:00 2001 From: Matias Fernandez <7898112+MatiasFernandez@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:49:55 -0300 Subject: [PATCH 3/4] Send every telemetry event to Scarf as well Scarf's dashboards are readable by people who never open App Insights. Every event now goes to both through the one send(). Scarf hears only from Production windows. Its readers never filter by extensionMode, so an F5 or test run must not reach it at all rather than be filtered out later. Its data is passed flat, so VS Code mixes common.* into the top level where scarfUrl reads it. common.os is not one of VS Code's own common properties; @vscode/extension-telemetry adds it to its own logger only, so the Scarf logger adds it the same way. ignoreUnhandledErrors is set because its default would forward the extension host's unhandled errors to the sender. docs/telemetry.md says what differs in Scarf, and that remote windows send from the remote machine, so a cloud host reads as its provider and should be left out when counting companies. The fake logger's common properties gain common.vscodemachineid, so a test can see the id reach Scarf through the allowlist. Co-Authored-By: Claude Opus 5.5 --- docs/telemetry.md | 24 +++++++++- src/__mocks__/vscode.ts | 12 +++-- src/__tests__/scarfTelemetry.test.ts | 68 +++++++++++++++++++++++++++- src/telemetry.ts | 31 ++++++++++++- 4 files changed, 125 insertions(+), 10 deletions(-) diff --git a/docs/telemetry.md b/docs/telemetry.md index 2a9fa9f..e65453c 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -3,8 +3,28 @@ What GemDB reports, and when. For what is collected overall, how it is stored, and how users turn it off, see [USAGE_DATA.md](../USAGE_DATA.md). -Events go to Azure Application Insights and are kept for 90 days. Nothing is -sent when a user sets VS Code's `telemetry.telemetryLevel` to `off`. +Events go to Azure Application Insights, where they are kept for 90 days, and +to Scarf. Nothing is sent to either when a user sets VS Code's +`telemetry.telemetryLevel` to `off`. + +Scarf receives the same events and properties, with three differences: + +- Only real installs send to it, so everything in Scarf is production data. + Events with an `extensionMode` of `development` or `test` go to Application + Insights alone. +- Every value arrives as a string, measures included: `activationMs` is + `"1234"`, not `1234`. +- Of VS Code's own `common.*` properties it gets only + `common.vscodemachineid`, `common.extversion`, `common.os`, + `common.vscodeversion` and `common.remotename`. + +Scarf names the company behind each event from the IP address it arrives +from. In a remote window GemDB runs on the remote machine, so the event comes +from there: over SSH to a cloud machine, or in Codespaces, it reads as the +cloud provider (Amazon, Microsoft, Google) rather than the user's employer. +When counting companies, leave out events whose `common.remotename` is set. +WSL, and a Dev Container on the user's own machine, still send from that +machine, so this undercounts them slightly rather than miscounting. ## The journey the events describe diff --git a/src/__mocks__/vscode.ts b/src/__mocks__/vscode.ts index 8d047f5..bf3ea2b 100644 --- a/src/__mocks__/vscode.ts +++ b/src/__mocks__/vscode.ts @@ -102,10 +102,11 @@ export const ExtensionMode = { Production: 1, Development: 2, Test: 3 } as const * Replicates VS Code's own merge quirk: when `data.properties` is falsy, its * real `TelemetryLogger` mixes `common.*` properties into the top level of * `data` instead of into `data.properties` — which is silently dropped by any - * reader that looks only at `data.properties`. Modelled here with one fake - * common property, so a call that forgets to pass a properties object (see + * reader that looks only at `data.properties`. Modelled here with fake common + * properties, so a call that forgets to pass a properties object (see * `telemetry.ts`'s own warning against ever doing that) is regression-tested - * rather than only commented on. + * rather than only commented on. `common.vscodemachineid` is there too, as one + * of the common properties Scarf is sent. * * Given a real sender — one with `sendEventData` — it hands every event to * it the way VS Code does: the name prefixed with the extension id, and the @@ -118,7 +119,10 @@ export const ExtensionMode = { Production: 1, Development: 2, Test: 3 } as const * (which passes no sender) sent, so a second sink is tested through the * sender's own effects without doubling every event recorded there. */ -const FAKE_COMMON_PROPERTIES: Record = { 'common.fake': 'yes' }; +const FAKE_COMMON_PROPERTIES: Record = { + 'common.fake': 'yes', + 'common.vscodemachineid': 'fake-machine-id', +}; /** What VS Code puts in front of every event name a sender receives: `./`. */ export const FAKE_EVENT_NAME_PREFIX = 'gemtalksystems.gemdb/'; diff --git a/src/__tests__/scarfTelemetry.test.ts b/src/__tests__/scarfTelemetry.test.ts index 2fbd8ae..c1fe1f2 100644 --- a/src/__tests__/scarfTelemetry.test.ts +++ b/src/__tests__/scarfTelemetry.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from 'vitest'; -import { FAKE_EVENT_NAME_PREFIX } from '../__mocks__/vscode'; +import { platform } from 'os'; +import { describe, expect, it, vi } from 'vitest'; +import { ExtensionMode, FAKE_EVENT_NAME_PREFIX } from '../__mocks__/vscode'; +import { GEMDB_STATE } from '../statusView'; import { ScarfSender, scarfUrl } from '../telemetry'; /** The query parameters of a Scarf request, decoded. */ @@ -71,3 +73,65 @@ describe('ScarfSender', () => { expect(paramsOf(urls[0])).toEqual({ event: 'activated', state: 'running' }); }); }); + +describe('sending to Scarf', () => { + // Each case gets a fresh telemetry.ts, so `pythonUsed`'s once-per-surface + // memory starts empty — and fresh mocks with it, which is why the recorders + // are re-imported alongside. + async function freshTelemetry(mode: number) { + vi.resetModules(); + const telemetry = await import('../telemetry'); + const support = await import('./telemetryTestSupport'); + const { __scarfRequests } = await import('../__mocks__/scarfTransport'); + const { __telemetry } = await import('../__mocks__/vscode'); + const context = support.fakeExtensionContext(); + Object.assign(context, { extensionMode: mode }); + telemetry.initTelemetry(context, false); + return { telemetry, scarfRequests: __scarfRequests, appInsights: __telemetry }; + } + + it('sends every event from a real install to both sinks', async () => { + const { telemetry, scarfRequests, appInsights } = await freshTelemetry( + ExtensionMode.Production, + ); + + telemetry.reportActivation(42, GEMDB_STATE.running); + + expect(scarfRequests).toHaveLength(1); + expect(paramsOf(scarfRequests[0])).toMatchObject({ + event: 'activated', + state: 'running', + activationMs: '42', + extensionMode: 'production', + 'common.vscodemachineid': 'fake-machine-id', + 'common.os': platform(), + }); + expect(appInsights.map((e) => e.name)).toEqual(['activated']); + }); + + it.each([ + ['a development host', ExtensionMode.Development], + ['a test run', ExtensionMode.Test], + ])('sends nothing to Scarf from %s', async (_label, mode) => { + const { telemetry, scarfRequests, appInsights } = await freshTelemetry(mode); + + telemetry.reportActivation(42, GEMDB_STATE.running); + + expect(scarfRequests).toEqual([]); + expect(appInsights.map((e) => e.name)).toEqual(['activated']); + }); + + it('sends measures as strings', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + const { telemetry, scarfRequests } = await freshTelemetry(ExtensionMode.Production); + vi.setSystemTime(Date.now() + 5 * 60_000); + + telemetry.reportPythonUsed(telemetry.SURFACE.notebook, telemetry.EVIDENCE.executed); + vi.useRealTimers(); + + expect(paramsOf(scarfRequests[0])).toMatchObject({ + event: 'pythonUsed', + minutesSinceFirstSeen: '5', + }); + }); +}); diff --git a/src/telemetry.ts b/src/telemetry.ts index 936625e..fac7c00 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -1,14 +1,18 @@ import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; import { TelemetryReporter } from '@vscode/extension-telemetry'; import type { DiskSnapshot } from './paths'; -import type { ScarfTransport } from './scarfTransport'; +import { scarfTransport, type ScarfTransport } from './scarfTransport'; import type { GemDbState } from './statusView'; /** * GemDB's telemetry: one named function per thing worth counting. * + * Every event goes to two sinks through the one `send` below: Azure App + * Insights, and Scarf. Scarf hears only from Production windows. + * * Four rules a future edit can break, none visible from a call site: * * 1. **This module, and `scarfTransport.ts` with it, must never reach @@ -136,6 +140,9 @@ export type EventName = (typeof EVENT)[keyof typeof EVENT]; let reporter: TelemetryReporter | undefined; +/** Scarf's logger, set only in a Production window; see `initTelemetry`. */ +let scarf: vscode.TelemetryLogger | undefined; + /** * Properties stamped on every event, established once at activation. * @@ -289,6 +296,22 @@ export function initTelemetry(context: vscode.ExtensionContext, databaseExists: // Disposal flushes queued events, so the subscription is load-bearing. reporter = new TelemetryReporter(CONNECTION_STRING); context.subscriptions.push(reporter); + // Scarf is sent production events only, rather than every event to be + // filtered later: its dashboards are read by people who never filter by + // `extensionMode`. + // `ignoreUnhandledErrors` defaults to false, which would forward the + // extension host's unhandled errors, stack traces and all, to the sender. + // `common.os` is not one of VS Code's own common properties: + // `@vscode/extension-telemetry` adds it, to its own logger only, so this + // one has to add it the same way. + scarf = + context.extensionMode === vscode.ExtensionMode.Production + ? vscode.env.createTelemetryLogger(new ScarfSender(scarfTransport), { + ignoreUnhandledErrors: true, + additionalCommonProperties: { 'common.os': os.platform() }, + }) + : undefined; + if (scarf) context.subscriptions.push(scarf); } /** @@ -314,7 +337,7 @@ export function registerCopyTelemetryIdCommand(context: vscode.ExtensionContext) * keeps `EVENT` the complete list of what GemDB emits, gives each event one * documented shape and one place to change it. * - * No consent check here, deliberately: `sendTelemetryEvent` goes through + * No consent check here, deliberately: both sinks go through * `vscode.env.createTelemetryLogger`, and VS Code checks `isTelemetryEnabled` * before it reaches any sender. Do not add one, and in particular do not read * `telemetry.telemetryLevel` — VS Code's own guide says it can disagree. @@ -334,6 +357,10 @@ function send( measures?: Record, ): void { reporter?.sendTelemetryEvent(name, { ...baseProperties, ...properties }, measures); + // Flat on purpose, the reverse of the rule above: with no `properties` + // field, VS Code mixes `common.*` into the top level, which is where + // `scarfUrl` reads every field. + scarf?.logUsage(name, { ...baseProperties, ...properties, ...measures }); } /** From 87240ac4948d994d6905d0441b29032c0c9ad119 Mon Sep 17 00:00:00 2001 From: Matias Fernandez <7898112+MatiasFernandez@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:51:07 -0300 Subject: [PATCH 4/4] Tell users their usage data also goes to Scarf USAGE_DATA.md names Scarf as a second processor: what it receives, the IP-to-company and location matching, why we want it, and that deletion through Copy Telemetry ID covers both. The "no other third party" line and the Disclosure section are corrected to match. Three facts about Scarf are left as TODO comments to confirm before merge: that the raw IP is discarded, its storage region and retention, and that it can delete by machine id. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ USAGE_DATA.md | 39 ++++++++++++++++++++++++++++----------- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c93962..96b8ab9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Usage data also goes to Scarf.** The same usage events GemDB already sends now go to + [Scarf](https://about.scarf.sh) as well, with fewer of VS Code's own properties. Turning + telemetry off in VS Code turns off both. [USAGE_DATA.md](USAGE_DATA.md) says what Scarf receives + and how to have your data deleted. - **The new GemDB brand.** The Marketplace and Extensions view icon is GemDB's dark-mode favicon, the gem on a blue gradient, and the activity bar shows the gem, recoloured to fit your theme with its right face lighter as in the logo. The Marketplace page's banner is the brand's Core Purple. diff --git a/USAGE_DATA.md b/USAGE_DATA.md index aef34ea..64e0278 100644 --- a/USAGE_DATA.md +++ b/USAGE_DATA.md @@ -50,13 +50,21 @@ stores those three fields alongside each event. Your IP address itself is do not use the location to identify anyone; it tells us which regions GemDB is used in. +**Scarf.** The same events also go to [Scarf](https://about.scarf.sh), with +GemDB's own properties and five of VS Code's: `common.vscodemachineid`, +`common.extversion`, `common.os`, `common.vscodeversion` and +`common.remotename`. VS Code's other common properties are not sent to Scarf. +Scarf matches the IP address an event arrives from to the company or +organisation it belongs to, if any, and to a coarse location. +The raw IP address is discarded once it has been matched. + ## What we do not collect File paths, file names, notebook URIs or titles, Python source code, notebook cell contents, query text, database contents, GemStone session or cache names, usernames, email addresses, environment variables, error messages, or -exception text, or your IP address (see "Approximate location" above for -what Azure derives from it before discarding it). Events that record a +exception text, or your IP address (see "Approximate location" and "Scarf" +above for what Azure and Scarf derive from it before discarding it). Events that record a failure — for example, whether setup or a database start succeeded — carry only a short classified reason (such as "cancelled" or "failed"), never the underlying error's own text, because GemStone errors can embed file paths. @@ -65,10 +73,10 @@ underlying error's own text, because GemStone errors can embed file paths. To understand GemDB's usage in aggregate: whether setup and startup succeed and how long they take, which platforms GemDB runs on and which regions it is -used in, and which features people actually use. That lets us prioritize -fixes and improvements. We do not use it to see what any individual does with -their data or code (see "What we do not collect"), and it is never used for -advertising or profiling. This relies on legitimate interest (GDPR Art. +used in, which organisations use it, and which features people actually use. +That lets us prioritize fixes and improvements. We do not use it to see what +any individual does with their data or code (see "What we do not collect"), +and it is never used for advertising or profiling. This relies on legitimate interest (GDPR Art. 6(1)(f)). ## How it is transmitted and stored @@ -78,17 +86,24 @@ Azure subscription owned by GemTalk. They are stored in the **West US 2** region (United States) and are **retained for 90 days**, after which Azure deletes them. +Events go to Scarf over TLS too, as web requests to a GemTalk endpoint +Scarf hosts. + +Scarf stores them in **TODO** and retains them for **TODO**. + ## Disclosure -Microsoft/Azure processes this data as our infrastructure provider. We do -not sell it, use it for advertising, or share it with any other third party. +Microsoft/Azure processes this data as our infrastructure provider, and +Scarf processes it as our usage analytics provider. We do not sell it, use it +for advertising, or share it with any third party beyond these two. ## Your controls GemDB honours VS Code's `telemetry.telemetryLevel` setting via `vscode.env.createTelemetryLogger`: -- `off` — no telemetry is sent. +- `off` — no telemetry is sent, to Azure or to Scarf. - `crash` / `error` — only events containing an error are sent. - `all` — all usage events described above are sent. @@ -98,9 +113,11 @@ Events are keyed only by `common.vscodemachineid`, so we cannot look up or delete data for you without that value. To get yours, run **GemDB: Copy Telemetry ID** from VS Code's Command Palette; it copies the value to your clipboard. If you send it to the contact address above, we will locate and -delete the matching records. +delete the matching records, in Azure and in Scarf. + ## Changes -Last updated: 2026-10-07. Changes to this notice will be published in this +Last updated: 2026-10-15. Changes to this notice will be published in this repository.