Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ export class Config {
};
} else {
this.telemetry = {
provider: parsedProvider,
provider: 'noop',
};
}

Expand Down
95 changes: 95 additions & 0 deletions src/telemetry/product_telemetry/telemetryForwarder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { DirectTelemetryForwarder, TableauTelemetryJsonEvent } from './telemetryForwarder.js';

describe('DirectTelemetryForwarder', () => {
const endpoint = 'https://qa.telemetry.tableausoftware.com';

const mockFetch = vi.fn();

beforeEach(() => {
mockFetch.mockImplementation(() => {
return Promise.resolve(new Response('', { status: 200 }));
});
vi.stubGlobal('fetch', mockFetch);
});

afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});

it('throws error when endpoint is empty', () => {
expect(() => new DirectTelemetryForwarder('')).toThrowError(
'Endpoint URL is required for DirectTelemetryForwarder',
);
});

it('sends telemetry with PUT method by default', async () => {
const eventType = 'test_event';
const properties = { action: 'click', count: 42 };

const forwarder = new DirectTelemetryForwarder(endpoint);
forwarder.send(eventType, properties);

expect(mockFetch).toHaveBeenCalledTimes(1);

const request = mockFetch.mock.calls[0][0] as Request;
expect(request.method).toBe('PUT');
expect(request.url).toContain(endpoint);
expect(request.credentials).toBe('omit');
expect(request.headers.get('Content-Type')).toBe('application/json');
expect(request.headers.get('Accept')).toBe('application/json');

const body = (await request.clone().json()) as TableauTelemetryJsonEvent[];

expect(body).toHaveLength(1);
expect(body[0]).toEqual(
expect.objectContaining({
type: eventType,
service_name: 'tableau-mcp',
properties,
pod: expect.any(String),
host_name: expect.any(String),
host_timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3} \+0000$/),
}),
);
});

it('can override HTTP method to POST', async () => {
const forwarder = new DirectTelemetryForwarder(endpoint, { httpMethod: 'POST' });
forwarder.send('event', { foo: 'bar' });

expect(mockFetch).toHaveBeenCalledTimes(1);

const request = mockFetch.mock.calls[0][0] as Request;
expect(request.method).toBe('POST');
});

it('uses default pod and host_name from environment', async () => {
const forwarder = new DirectTelemetryForwarder(endpoint);
forwarder.send('event', { foo: 'bar' });

expect(mockFetch).toHaveBeenCalledTimes(1);

const request = mockFetch.mock.calls[0][0] as Request;
const body = (await request.clone().json()) as TableauTelemetryJsonEvent[];

// pod comes from POD_NAME env var or defaults to 'External'
expect(body[0].pod).toBeDefined();
// host_name comes from os.hostname()
expect(body[0].host_name).toBeDefined();
});

it('uses default service_name', async () => {
const forwarder = new DirectTelemetryForwarder(endpoint);
forwarder.send('event', { foo: 'bar' });

expect(mockFetch).toHaveBeenCalledTimes(1);

const request = mockFetch.mock.calls[0][0] as Request;
const body = (await request.clone().json()) as TableauTelemetryJsonEvent[];

expect(body[0].service_name).toBe('tableau-mcp');
});
});
121 changes: 121 additions & 0 deletions src/telemetry/product_telemetry/telemetryForwarder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import os from 'os';

export type ValidPropertyValueType = string | number | boolean;
export type PropertiesType = { [key: string]: ValidPropertyValueType };
const DEFAULT_POD = 'External';
const DEFAULT_HOST_NAME = 'External';
const SERVICE_NAME = 'tableau-mcp';

export type TableauTelemetryJsonEvent = {
type: string;
host_timestamp: string;
host_name: string;
service_name: string;
pod?: string;
properties: PropertiesType;
};

export interface DirectTelemetryForwarderOptions {
/**
* HTTP method for sending events. Default: 'PUT'
*/
httpMethod?: 'POST' | 'PUT';
/**
* Service name. Default: 'tableau-mcp'
*/
serviceName?: string;
}

/**
* A simplified telemetry forwarder that sends events directly to Tableau's
* telemetry JSON endpoint (e.g., qa.telemetry.tableausoftware.com).
*/
export class DirectTelemetryForwarder {
private readonly endpoint: string;
private readonly httpMethod: 'POST' | 'PUT';

/**
* @param endpoint - The telemetry endpoint URL (e.g., 'https://qa.telemetry.tableausoftware.com')
* @param options - Optional configuration
*/
constructor(endpoint: string, options: DirectTelemetryForwarderOptions = {}) {
if (!endpoint) {
throw new Error('Endpoint URL is required for DirectTelemetryForwarder');
}

this.endpoint = endpoint;
this.httpMethod = options.httpMethod ?? 'PUT';
}

/**
* Build and send a telemetry event.
*
* @param eventType - The event type/name
* @param serviceName - The service name emitting the event
* @param properties - Key-value properties for the event
*/
public send(eventType: string, properties: PropertiesType): void {
const event: TableauTelemetryJsonEvent = {
type: eventType,
host_timestamp: formatHostTimestamp(new Date()),
service_name: SERVICE_NAME,
pod: getDefaultPod(),
host_name: getDefaultHostName(),
properties,
};

const init: RequestInit = {
method: this.httpMethod,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
cache: 'default',
mode: 'cors',
credentials: 'omit',
body: JSON.stringify([event]),
};

const req = new Request(this.endpoint, init);

// Debug logging
console.log('[Telemetry] Sending event:', JSON.stringify(event, null, 2));

fetch(req)
.then(async (res) => {
const body = await res.text();
if (!res.ok) {
console.error(`[Telemetry] Failed: ${res.status} ${res.statusText}`, body);
} else {
console.log(`[Telemetry] Success: ${res.status}`, body);
}
})
.catch((error) => console.error('[Telemetry] Network error:', error));
}
}

const getDefaultPod = (): string => {
return process.env.POD_NAME ?? DEFAULT_POD;
};

const getDefaultHostName = (): string => {
return os.hostname() ?? DEFAULT_HOST_NAME;
};

/**
* Format: "yyyy-MM-dd HH:mm:ss,SSS +0000" in UTC
*/
const formatHostTimestamp = (d: Date): string => {
const pad2 = (n: number): string => (n < 10 ? `0${n}` : `${n}`);
const pad3 = (n: number): string => (n < 10 ? `00${n}` : n < 100 ? `0${n}` : `${n}`);

const yyyy = d.getUTCFullYear();
const MM = pad2(d.getUTCMonth() + 1);
const dd = pad2(d.getUTCDate());
const HH = pad2(d.getUTCHours());
const mm = pad2(d.getUTCMinutes());
const ss = pad2(d.getUTCSeconds());
const SSS = pad3(d.getUTCMilliseconds());

return `${yyyy}-${MM}-${dd} ${HH}:${mm}:${ss},${SSS} +0000`;
};
3 changes: 2 additions & 1 deletion src/tools/listDatasources/listDatasources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,13 @@ export const getListDatasourcesTool = (server: Server): Tool<typeof paramsSchema
},
callback: async (
{ filter, pageSize, limit },
{ requestId, authInfo, signal },
{ requestId, authInfo, sessionId, signal },
): Promise<CallToolResult> => {
const config = getConfig();
const validatedFilter = filter ? parseAndValidateDatasourcesFilterString(filter) : undefined;
return await listDatasourcesTool.logAndExecute({
requestId,
sessionId,
authInfo,
args: { filter, pageSize, limit },
callback: async () => {
Expand Down
26 changes: 26 additions & 0 deletions src/tools/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,27 @@ import { Result } from 'ts-results-es';
import { z, ZodRawShape, ZodTypeAny } from 'zod';
import { fromError, isZodErrorLike } from 'zod-validation-error';

import { getConfig } from '../config.js';
import { getToolLogMessage, log } from '../logging/log.js';
import { Server } from '../server.js';
import { tableauAuthInfoSchema } from '../server/oauth/schemas.js';
import { getTelemetryProvider } from '../telemetry/init.js';
import { DirectTelemetryForwarder } from '../telemetry/product_telemetry/telemetryForwarder.js';
import { getExceptionMessage } from '../utils/getExceptionMessage.js';
import { Provider, TypeOrProvider } from '../utils/provider.js';
import { ToolName } from './toolName.js';

// Product telemetry - always enabled
const PRODUCT_TELEMETRY_ENDPOINT = 'https://qa.telemetry.tableausoftware.com';
let productTelemetry: DirectTelemetryForwarder | null = null;

function getProductTelemetry(): DirectTelemetryForwarder {
if (!productTelemetry) {
productTelemetry = new DirectTelemetryForwarder(PRODUCT_TELEMETRY_ENDPOINT);
}
return productTelemetry;
}

type ArgsValidator<Args extends ZodRawShape | undefined = undefined> = Args extends ZodRawShape
? (args: z.objectOutputType<Args, ZodTypeAny>) => void
: never;
Expand Down Expand Up @@ -71,6 +84,9 @@ type LogAndExecuteParams<T, E, Args extends ZodRawShape | undefined = undefined>
// The request ID of the tool call
requestId: RequestId;

// The session ID from the transport, if available
sessionId?: string;

// The Authentication info provided when OAuth is enabled
authInfo: AuthInfo | undefined;

Expand Down Expand Up @@ -161,6 +177,7 @@ export class Tool<Args extends ZodRawShape | undefined = undefined> {
// Implementation
async logAndExecute<T, E>({
requestId,
sessionId,
args,
authInfo,
callback,
Expand All @@ -181,6 +198,15 @@ export class Tool<Args extends ZodRawShape | undefined = undefined> {
request_id: requestId.toString(),
});

const config = getConfig();
const productTelemetry = getProductTelemetry();
productTelemetry.send('event_tool_call', {
tool_name: this.name,
request_id: requestId.toString(),
session_id: sessionId ?? '',
site_name: config.siteName,
});

if (args) {
try {
(await Provider.from(this.argsValidator))?.(args);
Expand Down
Loading