11import { Control } from "@/control"
2+ import { Config } from "@/config/config"
23import { Installation } from "@/installation"
34import { Log } from "@/util/log"
45
@@ -108,56 +109,106 @@ export namespace Telemetry {
108109 tokens_pruned : number
109110 }
110111
111- type Batch = {
112- session_id : string
113- cli_version : string
114- user_email : string
115- project_id : string
116- timestamp : number
117- events : Event [ ]
112+ type AppInsightsConfig = {
113+ iKey : string
114+ endpoint : string // e.g. https://xxx.applicationinsights.azure.com/v2/track
118115 }
119116
120117 let enabled = false
121- let authenticated = false
122118 let buffer : Event [ ] = [ ]
123119 let flushTimer : ReturnType < typeof setInterval > | undefined
124- let accountUrl = ""
125- let cachedToken = ""
126120 let userEmail = ""
127121 let sessionId = ""
128122 let projectId = ""
123+ let appInsights : AppInsightsConfig | undefined
129124
130- export async function init ( ) {
131- if ( enabled || flushTimer ) return
132- try {
133- const account = Control . account ( )
134- if ( account ) {
135- const token = await Control . token ( )
136- if ( token ) {
137- accountUrl = account . url
138- cachedToken = token
139- userEmail = account . email
140- authenticated = true
141- }
125+ function parseConnectionString ( cs : string ) : AppInsightsConfig | undefined {
126+ const parts : Record < string , string > = { }
127+ for ( const segment of cs . split ( ";" ) ) {
128+ const idx = segment . indexOf ( "=" )
129+ if ( idx === - 1 ) continue
130+ parts [ segment . slice ( 0 , idx ) . trim ( ) ] = segment . slice ( idx + 1 ) . trim ( )
131+ }
132+ const iKey = parts [ "InstrumentationKey" ]
133+ const ingestionEndpoint = parts [ "IngestionEndpoint" ]
134+ if ( ! iKey || ! ingestionEndpoint ) return undefined
135+ const base = ingestionEndpoint . endsWith ( "/" ) ? ingestionEndpoint : ingestionEndpoint + "/"
136+ return { iKey, endpoint : `${ base } v2/track` }
137+ }
138+
139+ function toAppInsightsEnvelopes ( events : Event [ ] , cfg : AppInsightsConfig ) : object [ ] {
140+ return events . map ( ( event ) => {
141+ const { type, timestamp, ...fields } = event as any
142+ const sid : string = fields . session_id ?? sessionId
143+
144+ const properties : Record < string , string > = {
145+ cli_version : Installation . VERSION ,
146+ project_id : fields . project_id ?? projectId ,
142147 }
148+ const measurements : Record < string , number > = { }
143149
144- // Fall back to env var for anonymous users
145- if ( ! accountUrl ) {
146- const envUrl = process . env . ALTIMATE_TELEMETRY_URL
147- if ( ! envUrl ) {
148- enabled = false
149- return
150+ // Flatten all fields — nested `tokens` object gets prefixed keys
151+ for ( const [ k , v ] of Object . entries ( fields ) ) {
152+ if ( k === "session_id" || k === "project_id" ) continue
153+ if ( k === "tokens" && typeof v === "object" && v !== null ) {
154+ for ( const [ tk , tv ] of Object . entries ( v as Record < string , unknown > ) ) {
155+ if ( typeof tv === "number" ) measurements [ `tokens_${ tk } ` ] = tv
156+ }
157+ } else if ( typeof v === "number" ) {
158+ measurements [ k ] = v
159+ } else if ( v !== undefined && v !== null ) {
160+ properties [ k ] = typeof v === "object" ? JSON . stringify ( v ) : String ( v )
150161 }
151- accountUrl = envUrl
152162 }
153163
154- enabled = true
164+ return {
165+ name : `Microsoft.ApplicationInsights.${ cfg . iKey } .Event` ,
166+ time : new Date ( timestamp ) . toISOString ( ) ,
167+ iKey : cfg . iKey ,
168+ tags : {
169+ "ai.session.id" : sid ,
170+ "ai.user.id" : userEmail ,
171+ "ai.cloud.role" : "altimate-code" ,
172+ "ai.application.ver" : Installation . VERSION ,
173+ } ,
174+ data : {
175+ baseType : "EventData" ,
176+ baseData : {
177+ ver : 2 ,
178+ name : type ,
179+ properties,
180+ measurements,
181+ } ,
182+ } ,
183+ }
184+ } )
185+ }
186+
187+ // Instrumentation key is intentionally public — safe to hardcode in client-side tooling.
188+ // Override with APPLICATIONINSIGHTS_CONNECTION_STRING env var for local dev / testing.
189+ const DEFAULT_CONNECTION_STRING =
190+ "InstrumentationKey=5095f5e6-477e-4262-b7ae-2118de18550d;IngestionEndpoint=https://eastus-8.in.applicationinsights.azure.com/;LiveEndpoint=https://eastus.livediagnostics.monitor.azure.com/;ApplicationId=6564474f-329b-4b7d-849e-e70cb4181294"
155191
192+ export async function init ( ) {
193+ if ( enabled || flushTimer ) return
194+ const userConfig = await Config . get ( )
195+ if ( userConfig . telemetry ?. disabled ) return
196+ try {
197+ // App Insights: env var overrides default (for dev/testing), otherwise use the baked-in key
198+ const connectionString = process . env . APPLICATIONINSIGHTS_CONNECTION_STRING ?? DEFAULT_CONNECTION_STRING
199+ const cfg = parseConnectionString ( connectionString )
200+ if ( ! cfg ) {
201+ enabled = false
202+ return
203+ }
204+ appInsights = cfg
205+ const account = Control . account ( )
206+ if ( account ) userEmail = account . email
207+ enabled = true
208+ log . info ( "telemetry initialized" , { mode : "appinsights" } )
156209 const timer = setInterval ( flush , FLUSH_INTERVAL_MS )
157210 if ( typeof timer === "object" && timer && "unref" in timer ) ( timer as any ) . unref ( )
158211 flushTimer = timer
159-
160- log . info ( "telemetry initialized" , { authenticated } )
161212 } catch {
162213 enabled = false
163214 }
@@ -181,54 +232,26 @@ export namespace Telemetry {
181232 }
182233
183234 export async function flush ( ) {
184- if ( ! enabled || buffer . length === 0 ) return
235+ if ( ! enabled || buffer . length === 0 || ! appInsights ) return
185236
186237 const events = buffer . splice ( 0 , buffer . length )
187- const batch : Batch = {
188- session_id : sessionId ,
189- cli_version : Installation . VERSION ,
190- user_email : userEmail ,
191- project_id : projectId ,
192- timestamp : Date . now ( ) ,
193- events,
194- }
195238
239+ const controller = new AbortController ( )
240+ const timeout = setTimeout ( ( ) => controller . abort ( ) , REQUEST_TIMEOUT_MS )
196241 try {
197- const headers : Record < string , string > = { "Content-Type" : "application/json" }
198- if ( authenticated && cachedToken ) {
199- headers [ "Authorization" ] = `Bearer ${ cachedToken } `
200- }
201-
202- const controller = new AbortController ( )
203- const timeout = setTimeout ( ( ) => controller . abort ( ) , REQUEST_TIMEOUT_MS )
204-
205- const response = await fetch ( `${ accountUrl } /api/observability/ingest` , {
242+ const response = await fetch ( appInsights . endpoint , {
206243 method : "POST" ,
207- headers,
208- body : JSON . stringify ( batch ) ,
244+ headers : { "Content-Type" : "application/json" } ,
245+ body : JSON . stringify ( toAppInsightsEnvelopes ( events , appInsights ) ) ,
209246 signal : controller . signal ,
210247 } )
211- clearTimeout ( timeout )
212-
213- if ( authenticated && response . status === 401 ) {
214- const newToken = await Control . token ( )
215- if ( ! newToken ) return
216- cachedToken = newToken
217- const retryController = new AbortController ( )
218- const retryTimeout = setTimeout ( ( ) => retryController . abort ( ) , REQUEST_TIMEOUT_MS )
219- await fetch ( `${ accountUrl } /api/observability/ingest` , {
220- method : "POST" ,
221- headers : {
222- "Content-Type" : "application/json" ,
223- Authorization : `Bearer ${ cachedToken } ` ,
224- } ,
225- body : JSON . stringify ( batch ) ,
226- signal : retryController . signal ,
227- } )
228- clearTimeout ( retryTimeout )
248+ if ( ! response . ok ) {
249+ log . debug ( "telemetry flush failed" , { status : response . status } )
229250 }
230251 } catch {
231252 // Silently drop on failure — telemetry must never break the CLI
253+ } finally {
254+ clearTimeout ( timeout )
232255 }
233256 }
234257
@@ -239,7 +262,7 @@ export namespace Telemetry {
239262 }
240263 await flush ( )
241264 enabled = false
242- authenticated = false
265+ appInsights = undefined
243266 buffer = [ ]
244267 sessionId = ""
245268 projectId = ""
0 commit comments