|
| 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 | +} |
0 commit comments