Skip to content

Commit 81d3c45

Browse files
feat(telemetry): introduce shared @uipath/telemetry package
Adds a new internal workspace package at packages/telemetry that emits Application Insights custom events through OpenTelemetry's batched log pipeline (LoggerProvider + BatchLogRecordProcessor). The package will be published to the registry on its own and consumed by the SDK and Coded Action Apps in a follow-up PR — deploying it first ensures the runtime dependency resolves when consumers pull it in. Public API: - TelemetryClient: per-consumer instantiable client. - getOrCreateClient(name): globalThis-keyed registry so subpath bundles in the same consumer share one client; different consumers get independent clients. - createTrack(client) / createTrackEvent(client): factories that bind a track decorator and trackEvent helper to a specific client. The connection string is patched into src/constants.ts at publish time (begins with \$ otherwise — the client treats unsubstituted placeholders as "no connection string" and silently no-ops). Tests cover initialize/track/registry/decorator/wrapper paths.
1 parent 3cf5b25 commit 81d3c45

12 files changed

Lines changed: 1256 additions & 0 deletions

File tree

packages/telemetry/package.json

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"name": "@uipath/telemetry",
3+
"version": "1.0.0",
4+
"description": "Shared telemetry package. Sends Application Insights custom events through OpenTelemetry's batched log pipeline.",
5+
"license": "MIT",
6+
"type": "module",
7+
"main": "./dist/index.js",
8+
"types": "./dist/index.d.ts",
9+
"exports": {
10+
".": {
11+
"types": "./dist/index.d.ts",
12+
"default": "./dist/index.js"
13+
}
14+
},
15+
"files": [
16+
"dist",
17+
"README.md"
18+
],
19+
"scripts": {
20+
"clean": "rimraf dist tsconfig.tsbuildinfo",
21+
"prebuild": "npm run clean",
22+
"build": "tsc",
23+
"typecheck": "tsc --noEmit",
24+
"lint": "oxlint",
25+
"test": "vitest run",
26+
"test:watch": "vitest"
27+
},
28+
"dependencies": {
29+
"@opentelemetry/api-logs": "^0.204.0",
30+
"@opentelemetry/sdk-logs": "^0.204.0"
31+
},
32+
"devDependencies": {
33+
"oxlint": "^1.43.0",
34+
"rimraf": "^6.0.1",
35+
"typescript": "^5.3.3",
36+
"vitest": "^3.2.4"
37+
},
38+
"repository": {
39+
"type": "git",
40+
"url": "git+https://github.com/UiPath/uipath-typescript.git",
41+
"directory": "packages/telemetry"
42+
},
43+
"publishConfig": {
44+
"registry": "https://npm.pkg.github.com"
45+
}
46+
}

packages/telemetry/src/client.ts

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
import type { Logger } from '@opentelemetry/api-logs';
2+
import {
3+
BatchLogRecordProcessor,
4+
LoggerProvider,
5+
} from '@opentelemetry/sdk-logs';
6+
import type {
7+
LogRecordExporter,
8+
ReadableLogRecord,
9+
} from '@opentelemetry/sdk-logs';
10+
11+
import {
12+
APP_NAME,
13+
CLOUD_CLIENT_ID,
14+
CLOUD_ORGANIZATION_NAME,
15+
CLOUD_REDIRECT_URI,
16+
CLOUD_TENANT_NAME,
17+
CLOUD_URL,
18+
CONNECTION_STRING,
19+
SERVICE,
20+
UNKNOWN,
21+
VERSION,
22+
} from './constants';
23+
import {
24+
TelemetryAttributes,
25+
TelemetryClientInitOptions,
26+
TelemetryContext,
27+
} from './types';
28+
29+
/** Result-callback parameter type derived from the OpenTelemetry interface. */
30+
type ExportResultCallback = Parameters<LogRecordExporter['export']>[1];
31+
32+
const INSTRUMENTATION_KEY_RE = /InstrumentationKey=([^;]+)/;
33+
const INGESTION_ENDPOINT_RE = /IngestionEndpoint=([^;]+)/;
34+
35+
/**
36+
* Application Insights ingestion payload shape (subset).
37+
*
38+
* Documented at:
39+
* https://learn.microsoft.com/azure/azure-monitor/app/data-model-event-telemetry
40+
*/
41+
interface ApplicationInsightsEnvelope {
42+
name: 'Microsoft.ApplicationInsights.Event';
43+
time: string;
44+
iKey: string;
45+
data: {
46+
baseType: 'EventData';
47+
baseData: {
48+
ver: 2;
49+
name: string;
50+
properties: Record<string, string>;
51+
};
52+
};
53+
tags: {
54+
'ai.cloud.role': string;
55+
'ai.cloud.roleInstance': string;
56+
};
57+
}
58+
59+
interface ApplicationInsightsTagsConfig {
60+
cloudRoleName: string;
61+
cloudRoleInstance: string;
62+
}
63+
64+
/**
65+
* Sends every emitted log record as an Application Insights custom event.
66+
* Plugged into OpenTelemetry's `BatchLogRecordProcessor`, which buffers and
67+
* flushes log records to this exporter on its own schedule.
68+
*/
69+
class ApplicationInsightsEventExporter implements LogRecordExporter {
70+
private readonly connectionString: string;
71+
private readonly tags: ApplicationInsightsTagsConfig;
72+
73+
constructor(connectionString: string, tags: ApplicationInsightsTagsConfig) {
74+
this.connectionString = connectionString;
75+
this.tags = tags;
76+
}
77+
78+
public export(
79+
logs: ReadableLogRecord[],
80+
resultCallback: ExportResultCallback
81+
): void {
82+
try {
83+
for (const log of logs) {
84+
this.sendAsCustomEvent(log);
85+
}
86+
resultCallback({ code: 0 });
87+
} catch (error) {
88+
const err = error instanceof Error ? error : new Error(String(error));
89+
console.debug('Failed to export logs to Application Insights:', err);
90+
resultCallback({ code: 1, error: err });
91+
}
92+
}
93+
94+
public shutdown(): Promise<void> {
95+
return Promise.resolve();
96+
}
97+
98+
private sendAsCustomEvent(logRecord: ReadableLogRecord): void {
99+
const eventName = String(logRecord.body ?? '');
100+
101+
const payload: ApplicationInsightsEnvelope = {
102+
name: 'Microsoft.ApplicationInsights.Event',
103+
time: new Date().toISOString(),
104+
iKey: this.extractInstrumentationKey(),
105+
data: {
106+
baseType: 'EventData',
107+
baseData: {
108+
ver: 2,
109+
name: eventName,
110+
properties: this.convertAttributesToProperties(logRecord.attributes),
111+
},
112+
},
113+
tags: {
114+
'ai.cloud.role': this.tags.cloudRoleName,
115+
'ai.cloud.roleInstance': this.tags.cloudRoleInstance,
116+
},
117+
};
118+
119+
void this.sendToApplicationInsights(payload);
120+
}
121+
122+
private extractInstrumentationKey(): string {
123+
const match = INSTRUMENTATION_KEY_RE.exec(this.connectionString);
124+
return match ? match[1] : '';
125+
}
126+
127+
private convertAttributesToProperties(
128+
attributes: ReadableLogRecord['attributes']
129+
): Record<string, string> {
130+
const properties: Record<string, string> = {};
131+
for (const [key, value] of Object.entries(attributes ?? {})) {
132+
properties[key] = String(value);
133+
}
134+
return properties;
135+
}
136+
137+
private async sendToApplicationInsights(
138+
payload: ApplicationInsightsEnvelope
139+
): Promise<void> {
140+
try {
141+
const ingestionEndpoint = this.extractIngestionEndpoint();
142+
if (!ingestionEndpoint) {
143+
console.debug('No ingestion endpoint found in connection string');
144+
return;
145+
}
146+
147+
const url = `${ingestionEndpoint}/v2/track`;
148+
const response = await fetch(url, {
149+
method: 'POST',
150+
headers: { 'Content-Type': 'application/json' },
151+
body: JSON.stringify(payload),
152+
});
153+
154+
if (!response.ok) {
155+
console.debug(
156+
`Failed to send event telemetry: ${response.status} ${response.statusText}`
157+
);
158+
}
159+
} catch (error) {
160+
console.debug('Error sending event telemetry to Application Insights:', error);
161+
}
162+
}
163+
164+
private extractIngestionEndpoint(): string {
165+
const match = INGESTION_ENDPOINT_RE.exec(this.connectionString);
166+
return match ? match[1] : '';
167+
}
168+
}
169+
170+
/**
171+
* Telemetry client owned by a single consumer (e.g. the SDK or the Coded
172+
* Action Apps package). Each consumer instantiates its own client so that
173+
* its identity (`cloudRoleName`, `serviceName`, `sdkVersion`, …) and tenant
174+
* context flow through to its own `Logger` and exporter pipeline. Two
175+
* consumers running in the same process emit independent events — they
176+
* share the Application Insights connection string but nothing else.
177+
*
178+
* Records are emitted via `Logger.emit` and batched by
179+
* `BatchLogRecordProcessor` before being handed to the Application Insights
180+
* exporter.
181+
*/
182+
export class TelemetryClient {
183+
private isInitialized = false;
184+
private options?: TelemetryClientInitOptions;
185+
private logProvider?: LoggerProvider;
186+
private logger?: Logger;
187+
private telemetryContext?: TelemetryContext;
188+
189+
public initialize(options: TelemetryClientInitOptions): void {
190+
if (this.isInitialized) {
191+
return;
192+
}
193+
194+
this.isInitialized = true;
195+
this.options = options;
196+
this.telemetryContext = options.context;
197+
198+
try {
199+
if (!this.isValidConnectionString(CONNECTION_STRING)) {
200+
return;
201+
}
202+
203+
this.setupTelemetryProvider(CONNECTION_STRING);
204+
} catch (error) {
205+
console.debug('Failed to initialize telemetry:', error);
206+
}
207+
}
208+
209+
public track(
210+
eventName: string,
211+
name?: string,
212+
extraAttributes: TelemetryAttributes = {}
213+
): void {
214+
try {
215+
if (!this.logger) {
216+
return;
217+
}
218+
219+
const finalDisplayName = name ?? eventName;
220+
const attributes = this.getEnrichedAttributes(extraAttributes, eventName);
221+
222+
this.logger.emit({
223+
body: finalDisplayName,
224+
attributes,
225+
timestamp: Date.now(),
226+
});
227+
} catch (error) {
228+
console.debug('Failed to track telemetry event:', error);
229+
}
230+
}
231+
232+
/**
233+
* Default event name (e.g. `Sdk.Run`) used when a tracker fires without
234+
* an explicit display name. Returns `undefined` until `initialize` runs.
235+
*/
236+
public getDefaultEventName(): string | undefined {
237+
return this.options?.defaultEventName;
238+
}
239+
240+
private setupTelemetryProvider(connectionString: string): void {
241+
// `setupTelemetryProvider` is only called from `initialize` after
242+
// `this.options` has been assigned, so the non-null assertion is safe.
243+
const opts = this.options!;
244+
245+
const exporter = new ApplicationInsightsEventExporter(connectionString, {
246+
cloudRoleName: opts.cloudRoleName,
247+
cloudRoleInstance: opts.sdkVersion,
248+
});
249+
const processor = new BatchLogRecordProcessor(exporter);
250+
251+
this.logProvider = new LoggerProvider({
252+
processors: [processor],
253+
});
254+
255+
this.logger = this.logProvider.getLogger(opts.loggerName);
256+
}
257+
258+
private isValidConnectionString(connectionString: string): boolean {
259+
// Build placeholders are emitted as `$CONNECTION_STRING` literally
260+
// until the publish workflow patches them. Treat any unsubstituted
261+
// placeholder as "no connection string available".
262+
return Boolean(connectionString) && !connectionString.startsWith('$');
263+
}
264+
265+
private getEnrichedAttributes(
266+
extraAttributes: TelemetryAttributes,
267+
eventName: string
268+
): TelemetryAttributes {
269+
const opts = this.options;
270+
return {
271+
[APP_NAME]: opts?.serviceName ?? UNKNOWN,
272+
[VERSION]: opts?.sdkVersion ?? UNKNOWN,
273+
[SERVICE]: eventName,
274+
[CLOUD_URL]: this.createCloudUrl(),
275+
[CLOUD_ORGANIZATION_NAME]: this.telemetryContext?.orgName ?? UNKNOWN,
276+
[CLOUD_TENANT_NAME]: this.telemetryContext?.tenantName ?? UNKNOWN,
277+
[CLOUD_REDIRECT_URI]: this.telemetryContext?.redirectUri ?? UNKNOWN,
278+
[CLOUD_CLIENT_ID]: this.telemetryContext?.clientId ?? UNKNOWN,
279+
...extraAttributes,
280+
};
281+
}
282+
283+
private createCloudUrl(): string {
284+
const baseUrl = this.telemetryContext?.baseUrl;
285+
const orgId = this.telemetryContext?.orgName;
286+
const tenantId = this.telemetryContext?.tenantName;
287+
288+
if (!baseUrl || !orgId || !tenantId) {
289+
return UNKNOWN;
290+
}
291+
292+
return `${baseUrl}/${orgId}/${tenantId}`;
293+
}
294+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* Common telemetry attribute keys + Application Insights connection string.
3+
*
4+
* Producer-specific identity (SDK version, role name, etc.) is supplied at
5+
* runtime via `TelemetryClient.initialize(...)` so the same package can be
6+
* reused by the SDK and by Coded Action Apps without embedding either's
7+
* identity here. The Application Insights connection string, however, is
8+
* shared across all consumers and is patched into this file at publish time
9+
* by the `publish-telemetry` job in `.github/workflows/publish.yml`.
10+
*/
11+
12+
/**
13+
* Application Insights connection string. Build placeholder — patched by
14+
* the telemetry package's publish workflow. Until then it begins with `$`,
15+
* which `TelemetryClient` treats as "no connection string available" and
16+
* silently no-ops every `track()` call.
17+
*/
18+
export const CONNECTION_STRING = '$CONNECTION_STRING';
19+
20+
export const VERSION = 'Version';
21+
export const SERVICE = 'Service';
22+
export const CLOUD_ORGANIZATION_NAME = 'CloudOrganizationName';
23+
export const CLOUD_TENANT_NAME = 'CloudTenantName';
24+
export const CLOUD_URL = 'CloudUrl';
25+
export const CLOUD_CLIENT_ID = 'CloudClientId';
26+
export const CLOUD_REDIRECT_URI = 'CloudRedirectUri';
27+
export const APP_NAME = 'ApplicationName';
28+
29+
/** Default value used when an attribute has no resolved value. */
30+
export const UNKNOWN = '';

0 commit comments

Comments
 (0)