Skip to content

Commit 1ab6dff

Browse files
feat(W-18699160): add telemetry (#50)
* feat: add telemetry * fix: ensure all events have same props * chore: clean up * feat: add runtimeMs * fix: handle failed connection to appinsights * chore: extract method signatures * chore: code review * feat: add client info to telemetry events * fix: init telemetry in catch * fix: consolidate TOOL_CALLED and TOOL_ERROR * fix: dont log start error if --no-telemetry --------- Co-authored-by: Cristian Dominguez <cdominguez@salesforce.com>
1 parent 7a87865 commit 1ab6dff

File tree

10 files changed

+654
-52
lines changed

10 files changed

+654
-52
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"@salesforce/kit": "^3.1.6",
4747
"@salesforce/source-deploy-retrieve": "^12.19.7",
4848
"@salesforce/source-tracking": "^7.4.1",
49+
"@salesforce/telemetry": "^6.0.39",
4950
"@salesforce/ts-types": "^2.0.11",
5051
"zod": "^3.25.42"
5152
},

src/index.ts

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616

1717
/* eslint-disable no-console */
1818

19-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2019
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
2120
import { Command, Flags, ux } from '@oclif/core';
2221
import * as core from './tools/core/index.js';
@@ -25,9 +24,30 @@ import * as data from './tools/data/index.js';
2524
import * as users from './tools/users/index.js';
2625
import * as metadata from './tools/metadata/index.js';
2726
import Cache from './shared/cache.js';
27+
import { Telemetry } from './telemetry.js';
28+
import { SfMcpServer } from './sf-mcp-server.js';
2829

2930
const TOOLSETS = ['all', 'orgs', 'data', 'users', 'metadata'] as const;
3031

32+
/**
33+
* Sanitizes an array of org usernames by replacing specific orgs with a placeholder.
34+
* Special values (DEFAULT_TARGET_ORG, DEFAULT_TARGET_DEV_HUB, ALLOW_ALL_ORGS) are preserved.
35+
*
36+
* @param {string[]} input - Array of org identifiers to sanitize
37+
* @returns {string} Comma-separated string of sanitized org identifiers
38+
*/
39+
function sanitizeOrgInput(input: string[]): string {
40+
return input
41+
.map((org) => {
42+
if (org === 'DEFAULT_TARGET_ORG' || org === 'DEFAULT_TARGET_DEV_HUB' || org === 'ALLOW_ALL_ORGS') {
43+
return org;
44+
}
45+
46+
return 'SANITIZED_ORG';
47+
})
48+
.join(', ');
49+
}
50+
3151
export default class McpServerCommand extends Command {
3252
public static summary = 'Start the Salesforce MCP server';
3353
public static description = `This command starts the Model Context Protocol (MCP) server for Salesforce, allowing access to various tools and orgs.
@@ -50,7 +70,7 @@ You can also use special values to control access to orgs:
5070
delimiter: ',',
5171
parse: async (input: string) => {
5272
if (input === 'ALLOW_ALL_ORGS') {
53-
ux.warn('WARNING: ALLOW_ALL_ORGS is set. This allows access to all authenticated orgs. Use with caution.');
73+
ux.warn('ALLOW_ALL_ORGS is set. This allows access to all authenticated orgs. Use with caution.');
5474
}
5575

5676
if (
@@ -76,6 +96,9 @@ You can also use special values to control access to orgs:
7696
default: ['all'],
7797
})(),
7898
version: Flags.version(),
99+
'no-telemetry': Flags.boolean({
100+
summary: 'Disable telemetry',
101+
}),
79102
};
80103

81104
public static examples = [
@@ -93,18 +116,38 @@ You can also use special values to control access to orgs:
93116
},
94117
];
95118

119+
private telemetry?: Telemetry;
120+
96121
public async run(): Promise<void> {
97122
const { flags } = await this.parse(McpServerCommand);
123+
124+
if (!flags['no-telemetry']) {
125+
this.telemetry = new Telemetry(this.config, {
126+
toolsets: flags.toolsets.join(', '),
127+
orgs: sanitizeOrgInput(flags.orgs),
128+
});
129+
130+
await this.telemetry.start();
131+
132+
process.stdin.on('close', (err) => {
133+
this.telemetry?.sendEvent(err ? 'SERVER_STOPPED_ERROR' : 'SERVER_STOPPED_SUCCESS');
134+
this.telemetry?.stop();
135+
});
136+
}
137+
98138
Cache.getInstance().set('allowedOrgs', new Set(flags.orgs));
99139
this.logToStderr(`Allowed orgs:\n${flags.orgs.map((org) => `- ${org}`).join('\n')}`);
100-
const server = new McpServer({
101-
name: 'sf-mcp-server',
102-
version: this.config.version,
103-
capabilities: {
104-
resources: {},
105-
tools: {},
140+
const server = new SfMcpServer(
141+
{
142+
name: 'sf-mcp-server',
143+
version: this.config.version,
144+
capabilities: {
145+
resources: {},
146+
tools: {},
147+
},
106148
},
107-
});
149+
{ telemetry: this.telemetry }
150+
);
108151

109152
// // TODO: Should we add annotations to our tools? https://modelcontextprotocol.io/docs/concepts/tools#tool-definition-structure
110153
// // TODO: Move tool names into a shared file, that way if we reference them in multiple places, we can update them in one place
@@ -156,4 +199,18 @@ You can also use special values to control access to orgs:
156199
await server.connect(transport);
157200
console.error(`✅ Salesforce MCP Server v${this.config.version} running on stdio`);
158201
}
202+
203+
protected async catch(error: Error): Promise<void> {
204+
if (!this.telemetry && !process.argv.includes('--no-telemetry')) {
205+
this.telemetry = new Telemetry(this.config);
206+
await this.telemetry.start();
207+
}
208+
209+
this.telemetry?.sendEvent('START_ERROR', {
210+
error: error.message,
211+
stack: error.stack,
212+
});
213+
214+
await super.catch(error);
215+
}
159216
}

src/sf-mcp-server.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
* Copyright 2025, Salesforce, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { McpServer, RegisteredTool, ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js';
18+
import { CallToolResult, Implementation, ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types.js';
19+
import { ServerOptions } from '@modelcontextprotocol/sdk/server/index.js';
20+
import { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
21+
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
22+
import { Telemetry } from './telemetry.js';
23+
24+
type ToolMethodSignatures = {
25+
tool: McpServer['tool'];
26+
connect: McpServer['connect'];
27+
};
28+
29+
/**
30+
* A server implementation that extends the base MCP server with telemetry capabilities.
31+
*
32+
* The method overloads for `tool` are taken directly from the source code for the original McpServer. They're
33+
* copied here so that the types don't get lost.
34+
*
35+
* @extends {McpServer}
36+
*/
37+
export class SfMcpServer extends McpServer implements ToolMethodSignatures {
38+
/** Optional telemetry instance for tracking server events */
39+
private telemetry?: Telemetry;
40+
41+
/**
42+
* Creates a new SfMcpServer instance
43+
*
44+
* @param {Implementation} serverInfo - The server implementation details
45+
* @param {ServerOptions & { telemetry?: Telemetry }} [options] - Optional server configuration including telemetry
46+
*/
47+
public constructor(serverInfo: Implementation, options?: ServerOptions & { telemetry?: Telemetry }) {
48+
super(serverInfo, options);
49+
this.telemetry = options?.telemetry;
50+
this.server.oninitialized = (): void => {
51+
const clientInfo = this.server.getClientVersion();
52+
if (clientInfo) {
53+
this.telemetry?.addAttributes({
54+
clientName: clientInfo.name,
55+
clientVersion: clientInfo.version,
56+
});
57+
}
58+
this.telemetry?.sendEvent('SERVER_START_SUCCESS');
59+
};
60+
}
61+
62+
public connect: McpServer['connect'] = async (transport: Transport): Promise<void> => {
63+
try {
64+
await super.connect(transport);
65+
if (!this.isConnected()) {
66+
this.telemetry?.sendEvent('SERVER_START_ERROR', {
67+
error: 'Server not connected',
68+
});
69+
}
70+
} catch (error: unknown) {
71+
this.telemetry?.sendEvent('SERVER_START_ERROR', {
72+
error: error instanceof Error ? error.message : 'Unknown error',
73+
stack: error instanceof Error ? error.stack : undefined,
74+
});
75+
}
76+
};
77+
78+
public tool: McpServer['tool'] = (name: string, ...rest: unknown[]): RegisteredTool => {
79+
// Given the signature of the tool function, the last argument is always the callback
80+
const cb = rest[rest.length - 1] as ToolCallback;
81+
82+
const wrappedCb = async (args: RequestHandlerExtra<ServerRequest, ServerNotification>): Promise<CallToolResult> => {
83+
const startTime = Date.now();
84+
const result = await cb(args);
85+
const runtimeMs = Date.now() - startTime;
86+
87+
this.telemetry?.sendEvent('TOOL_CALLED', {
88+
name,
89+
runtimeMs,
90+
isError: result.isError,
91+
});
92+
93+
return result;
94+
};
95+
96+
// @ts-expect-error because we no longer know what the type of rest is
97+
return super.tool(name, ...rest.slice(0, -1), wrappedCb);
98+
};
99+
}

src/telemetry.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/*
2+
* Copyright 2025, Salesforce, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { randomBytes } from 'node:crypto';
18+
import { readFileSync } from 'node:fs';
19+
import { join } from 'node:path';
20+
import { Attributes, TelemetryReporter } from '@salesforce/telemetry';
21+
import { warn } from '@oclif/core/ux';
22+
import { Config } from '@oclif/core';
23+
24+
const PROJECT = 'salesforce-mcp-server';
25+
const APP_INSIGHTS_KEY =
26+
'InstrumentationKey=2ca64abb-6123-4c7b-bd9e-4fe73e71fe9c;IngestionEndpoint=https://eastus-1.in.applicationinsights.azure.com/;LiveEndpoint=https://eastus.livediagnostics.monitor.azure.com/;ApplicationId=ecd8fa7a-0e0d-4109-94db-4d7878ada862';
27+
28+
const generateRandomId = (): string => randomBytes(20).toString('hex');
29+
30+
const getCliId = (cacheDir: string): string => {
31+
// We need to find sf's cache directory and read the CLIID.txt file from there.
32+
// The problem is that sf's cache directory is OS specific and we don't want to
33+
// hardcode all the potential paths. oclif does this for us already during startup
34+
// so we can simply replace sf-mcp-server with sf in the cache directory path and
35+
// end up with the correct OS specific path.
36+
//
37+
// The only downside to this approach is that the user could have a different
38+
// cache directory set via env var. In that case, we'll just generate a new CLIID.
39+
// This is a very rare case and we can live with it for now.
40+
const sfCacheDir = cacheDir.replace('sf-mcp-server', 'sf');
41+
const cliIdPath = join(sfCacheDir, 'CLIID.txt');
42+
try {
43+
return readFileSync(cliIdPath, 'utf-8');
44+
} catch {
45+
return generateRandomId();
46+
}
47+
};
48+
49+
class McpTelemetryReporter extends TelemetryReporter {
50+
/**
51+
* TelemetryReporter references sf's config to determine if telemetry is enabled.
52+
* We want to always send telemetry events, so we override the method to always return true.
53+
* This is okay to do since the Telemetry class won't be instantiated in the MCP server if telemetry is disabled.
54+
*
55+
* @returns true
56+
*/
57+
// eslint-disable-next-line class-methods-use-this
58+
public isSfdxTelemetryEnabled(): boolean {
59+
return true;
60+
}
61+
}
62+
63+
export class Telemetry {
64+
/**
65+
* A unique identifier for the session.
66+
*/
67+
private sessionId: string;
68+
/**
69+
* The unique identifier generated for the user by the `sf` CLI.
70+
* If it doesn't exist, or we can't read it, we'll generate a new one.
71+
*/
72+
private cliId: string;
73+
private started = false;
74+
private reporter?: McpTelemetryReporter;
75+
76+
public constructor(private readonly config: Config, private attributes: Attributes = {}) {
77+
warn(
78+
'You acknowledge and agree that the MCP server may collect usage information, user environment, and crash reports for the purposes of providing services or functions that are relevant to use of the MCP server and product improvements.'
79+
);
80+
this.sessionId = generateRandomId();
81+
this.cliId = getCliId(config.cacheDir);
82+
}
83+
84+
public addAttributes(attributes: Attributes): void {
85+
this.attributes = { ...this.attributes, ...attributes };
86+
}
87+
88+
public sendEvent(eventName: string, attributes?: Attributes): void {
89+
try {
90+
this.reporter?.sendTelemetryEvent(eventName, {
91+
...this.attributes,
92+
...attributes,
93+
// Identifiers
94+
sessionId: this.sessionId,
95+
cliId: this.cliId,
96+
// System information
97+
version: this.config.version,
98+
platform: this.config.platform,
99+
arch: this.config.arch,
100+
nodeVersion: process.version,
101+
nodeEnv: process.env.NODE_ENV,
102+
origin: this.config.userAgent,
103+
// Timestamps
104+
date: new Date().toUTCString(),
105+
timestamp: String(Date.now()),
106+
processUptime: process.uptime() * 1000,
107+
});
108+
} catch {
109+
/* empty */
110+
}
111+
}
112+
113+
public async start(): Promise<void> {
114+
if (this.started) return;
115+
this.started = true;
116+
117+
try {
118+
this.reporter = await McpTelemetryReporter.create({
119+
project: PROJECT,
120+
key: APP_INSIGHTS_KEY,
121+
userId: this.cliId,
122+
waitForConnection: true,
123+
});
124+
125+
this.reporter.start();
126+
} catch {
127+
// connection probably failed, but we can continue without telemetry
128+
}
129+
}
130+
131+
public stop(): void {
132+
if (!this.started) return;
133+
this.started = false;
134+
this.reporter?.stop();
135+
}
136+
}

src/tools/core/sf-get-username.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@
1515
*/
1616

1717
import { z } from 'zod';
18-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1918
import { textResponse } from '../../shared/utils.js';
2019
import { getDefaultTargetOrg, getDefaultTargetDevHub, suggestUsername } from '../../shared/auth.js';
2120
import { directoryParam } from '../../shared/params.js';
2221
import { type ConfigInfoWithCache, type ToolTextResponse } from '../../shared/types.js';
22+
import { SfMcpServer } from '../../sf-mcp-server.js';
2323

2424
/*
2525
* Get username for Salesforce org
@@ -58,7 +58,7 @@ Get username for my default dev hub
5858

5959
export type GetUsernameParamsSchema = z.infer<typeof getUsernameParamsSchema>;
6060

61-
export const registerToolGetUsername = (server: McpServer): void => {
61+
export const registerToolGetUsername = (server: SfMcpServer): void => {
6262
server.tool(
6363
'sf-get-username',
6464
`Intelligently determines the appropriate username or alias for Salesforce operations.

0 commit comments

Comments
 (0)