Skip to content

Commit f269f3d

Browse files
authored
Merge pull request #37 from AltimateAI/feat/azure-appinsights-telemetry
feat: route telemetry directly to Azure Application Insights
2 parents 2cc9767 + 9348230 commit f269f3d

File tree

4 files changed

+136
-124
lines changed

4 files changed

+136
-124
lines changed

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/altimate-code/src/config/config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,6 +1184,12 @@ export namespace Config {
11841184
.describe("Token buffer for compaction. Leaves enough window to avoid overflow during compaction."),
11851185
})
11861186
.optional(),
1187+
telemetry: z
1188+
.object({
1189+
disabled: z.boolean().optional().describe("Disable usage telemetry (default: false)"),
1190+
})
1191+
.optional()
1192+
.describe("Telemetry settings"),
11871193
experimental: z
11881194
.object({
11891195
disable_paste_summary: z.boolean().optional(),
Lines changed: 35 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import z from "zod"
2-
import { randomBytes } from "crypto"
2+
import { monotonicFactory, decodeTime } from "ulid"
3+
4+
const ulid = monotonicFactory()
35

46
export namespace Identifier {
57
const prefixes = {
@@ -13,71 +15,51 @@ export namespace Identifier {
1315
tool: "tool",
1416
} as const
1517

16-
export function schema(prefix: keyof typeof prefixes) {
17-
return z.string().startsWith(prefixes[prefix])
18-
}
19-
20-
const LENGTH = 26
21-
22-
// State for monotonic ID generation
23-
let lastTimestamp = 0
24-
let counter = 0
25-
26-
export function ascending(prefix: keyof typeof prefixes, given?: string) {
27-
return generateID(prefix, false, given)
18+
// Crockford base32 alphabet used by ULID
19+
const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
20+
21+
// Invert each character within the Crockford alphabet so the ID sorts in reverse chronological order
22+
function invert(id: string): string {
23+
return id
24+
.split("")
25+
.map((c) => {
26+
const idx = CROCKFORD.indexOf(c.toUpperCase())
27+
return idx === -1 ? c : CROCKFORD[31 - idx]
28+
})
29+
.join("")
2830
}
2931

30-
export function descending(prefix: keyof typeof prefixes, given?: string) {
31-
return generateID(prefix, true, given)
32+
export function schema(prefix: keyof typeof prefixes) {
33+
return z.string().startsWith(prefixes[prefix])
3234
}
3335

34-
function generateID(prefix: keyof typeof prefixes, descending: boolean, given?: string): string {
35-
if (!given) {
36-
return create(prefix, descending)
36+
export function ascending(prefix: keyof typeof prefixes, given?: string): string {
37+
if (given) {
38+
if (!given.startsWith(prefixes[prefix])) throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
39+
return given
3740
}
38-
39-
if (!given.startsWith(prefixes[prefix])) {
40-
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
41-
}
42-
return given
41+
return prefixes[prefix] + "_" + ulid()
4342
}
4443

45-
function randomBase62(length: number): string {
46-
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
47-
let result = ""
48-
const bytes = randomBytes(length)
49-
for (let i = 0; i < length; i++) {
50-
result += chars[bytes[i] % 62]
44+
export function descending(prefix: keyof typeof prefixes, given?: string): string {
45+
if (given) {
46+
if (!given.startsWith(prefixes[prefix])) throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
47+
return given
5148
}
52-
return result
49+
return prefixes[prefix] + "_" + invert(ulid())
5350
}
5451

55-
export function create(prefix: keyof typeof prefixes, descending: boolean, timestamp?: number): string {
56-
const currentTimestamp = timestamp ?? Date.now()
57-
58-
if (currentTimestamp !== lastTimestamp) {
59-
lastTimestamp = currentTimestamp
60-
counter = 0
61-
}
62-
counter++
63-
64-
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
65-
66-
now = descending ? ~now : now
67-
68-
const timeBytes = Buffer.alloc(6)
69-
for (let i = 0; i < 6; i++) {
70-
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
71-
}
72-
73-
return prefixes[prefix] + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
52+
export function create(prefix: keyof typeof prefixes, desc: boolean, timestamp?: number): string {
53+
const id = ulid(timestamp)
54+
return prefixes[prefix] + "_" + (desc ? invert(id) : id)
7455
}
7556

7657
/** Extract timestamp from an ascending ID. Does not work with descending IDs. */
7758
export function timestamp(id: string): number {
78-
const prefix = id.split("_")[0]
79-
const hex = id.slice(prefix.length + 1, prefix.length + 13)
80-
const encoded = BigInt("0x" + hex)
81-
return Number(encoded / BigInt(0x1000))
59+
const ulidPart = id.slice(id.indexOf("_") + 1)
60+
if (ulidPart.charCodeAt(0) > "9".charCodeAt(0)) {
61+
throw new Error("timestamp() does not work with descending IDs")
62+
}
63+
return decodeTime(ulidPart)
8264
}
8365
}

packages/altimate-code/src/telemetry/index.ts

Lines changed: 94 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Control } from "@/control"
2+
import { Config } from "@/config/config"
23
import { Installation } from "@/installation"
34
import { 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

Comments
 (0)