feat: implemented provider wire codecs and optimized telemetry and parsing

- Added internal protobuf wire codecs, message builders, and protocol definitions for Cursor and Devin providers.
- Deferred loading of OTel SDK and OTLP exporters and added bounded caches to optimize startup and lookup performance.
- Added SQLite-backed parse caching for legacy extension source analysis and streaming file chunk parsing for changelogs.
- Added support for rendering context usage overflow above 100% in the status line component.
This commit is contained in:
can1357
2026-08-20 07:18:19 +02:00
parent d820cf75bb
commit df0bb6e31a
90 changed files with 198107 additions and 92186 deletions
+1
View File
@@ -25,6 +25,7 @@
- Compaction method priorities — `compaction.methodOrder` takes an ordered preference list (e.g. `[remote, snap]` uses remote compaction where the provider supports it, such as OpenAI, and snap everywhere else), replacing `compaction.strategy`/`compaction.remoteEnabled`.
- Unified inline overlays and selectors (model picker, settings, `/cleanse`) into one titled rounded-box panel style.
- Risk badges and warnings on `/settings` rows, starting with External Thinking.
- Faster CLI Startup
### Fixed
@@ -22,6 +22,7 @@ import { memoryFixtures } from "./memory";
import { miscFixtures } from "./misc";
import { searchFixtures } from "./search";
import { shellFixtures } from "./shell";
import { statusLineFixtures } from "./status-line";
import { webFixtures } from "./web";
export * from "./types";
@@ -36,5 +37,6 @@ export const galleryFixtures = {
...memoryFixtures,
...webFixtures,
...codeintelFixtures,
...statusLineFixtures,
...miscFixtures,
};
@@ -0,0 +1,94 @@
/**
* Gallery fixture for the status-line context gauge — the box top border that
* bridges the segment groups. Renders the real `StatusLineComponent` against a
* fake session at four usage levels, mapping the gallery lifecycle states to
* fill levels; `error` shows the >100% overflow case (usage anchored to a
* larger window than the active model's, e.g. after switching to a smaller
* model mid-session), where the gauge clamps to full while the context_pct
* segment reports the raw percent.
*/
import { StatusLineComponent } from "../../modes/components/status-line";
import { theme } from "../../modes/theme/theme";
import type { AgentSession } from "../../session/agent-session";
import type { GalleryFixture, GalleryFixtureState } from "./types";
const GAUGE_WINDOW = 200_000;
/** Simulated usage per gallery lifecycle state, against {@link GAUGE_WINDOW}. */
const GAUGE_CASES: Record<GalleryFixtureState, { tokens: number; note: string }> = {
streaming: { tokens: 6_000, note: "3% used — fresh session" },
progress: { tokens: 124_000, note: "62% used — warning zone" },
success: { tokens: 194_000, note: "97% used — past compaction threshold" },
error: { tokens: 240_000, note: "120% used — overflow: percent breaks past the window label in red" },
};
/** Minimal session double satisfying every query `getTopBorder` makes. */
function fakeGaugeSession(tokens: number): AgentSession {
const model = { id: "test-model", contextWindow: GAUGE_WINDOW };
const messages = [{ role: "user", content: "hi" }];
return {
messages,
systemPrompt: [],
agent: { state: { tools: [] } },
skills: [],
model,
modelRegistry: { isUsingOAuth: () => false },
state: { messages, model },
settings: undefined,
sessionManager: {
getUsageStatistics: () => ({
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
orchestrationInput: 0,
orchestrationOutput: 0,
orchestrationCacheRead: 0,
premiumRequests: 0,
cost: 0,
}),
getSessionName: () => "gallery",
},
getAsyncJobSnapshot: () => ({ running: [] }),
isFastModeActive: () => false,
getContextUsage: () => ({ tokens, contextWindow: GAUGE_WINDOW, percent: (tokens / GAUGE_WINDOW) * 100 }),
contextUsageRevision: 0,
} as unknown as AgentSession;
}
/** Render one contextLine variant of the top border for the given usage. */
function renderGaugeVariant(tokens: number, contextLine: "annotated" | "embedded", width: number): string {
const component = new StatusLineComponent(fakeGaugeSession(tokens));
component.updateSettings({
preset: "custom",
leftSegments: contextLine === "embedded" ? ["model", "context_pct"] : ["model"],
rightSegments: contextLine === "embedded" ? ["session_name"] : ["context_pct"],
separator: "powerline-thin",
sessionAccent: false,
contextLine,
});
try {
return component.getTopBorder(width).content;
} finally {
component.dispose();
}
}
function renderContextGaugeState(state: GalleryFixtureState, width: number): readonly string[] {
const { tokens, note } = GAUGE_CASES[state];
return [
theme.fg("dim", ` ${note}`),
renderGaugeVariant(tokens, "annotated", width),
renderGaugeVariant(tokens, "embedded", width),
];
}
export const statusLineFixtures: Record<string, GalleryFixture> = {
context_gauge: {
label: "Context Gauge",
renderState: renderContextGaugeState,
args: { note: "status-line context gauge preview" },
result: { content: [{ type: "text", text: "Rendered annotated and embedded context gauges." }] },
},
};
@@ -164,15 +164,6 @@ export function mergeProviderRemoteCompactionConfig(
return mergeRemoteCompactionConfig(providerConfig, modelConfig);
}
/**
* Project a built model back to spec shape for the model-manager/cache
* boundary: sparse compat comes from `compatConfig`, never from the resolved
* record.
*/
export function toModelSpec<TApi extends Api>(model: Model<TApi>): ModelSpec<TApi> {
return { ...model, compat: model.compatConfig } as ModelSpec<TApi>;
}
/**
* The patchable subset of `Model` fields shared by `modelOverrides` entries,
* custom model definitions, and parsed custom-model overlays. `undefined`
@@ -31,6 +31,7 @@ import {
resolveModelCacheProviderId,
resolveOllamaModelCacheProviderId,
} from "@oh-my-pi/pi-catalog/provider-models";
import { toModelSpec } from "@oh-my-pi/pi-catalog/provider-models/bundled-references";
import { collapseBuiltModelVariants } from "@oh-my-pi/pi-catalog/variant-collapse";
import { getAgentDir, isBunTestRuntime, logger, wrapFetchForExtraCa } from "@oh-my-pi/pi-utils";
import { resolveProviderModelReference } from "../config/model-resolver";
@@ -80,7 +81,6 @@ import {
mergeRemoteCompactionConfig,
type ProviderOverride,
providersWithAuthoritativeProjectCatalog,
toModelSpec,
} from "./model-patch";
import {
BUILT_IN_DISCOVERY_CACHE_TTL_MS,
@@ -646,7 +646,7 @@ export class ModelRegistry {
return models.map(m => {
if (!providerOverride) return m;
const withTransportOverride = this.#applyProviderTransportOverride(m, providerOverride);
const withTransportOverride = this.#applyProviderTransportOverride(toModelSpec(m), providerOverride);
return buildModel({
...withTransportOverride,
compat: mergeCompat(m.compatConfig, providerOverride.compat),
@@ -1,12 +1,18 @@
/// <reference path="./legacy-pi-virtual-modules.d.ts" />
import { Database } from "bun:sqlite";
import * as fs from "node:fs";
import { createRequire, isBuiltin } from "node:module";
import * as path from "node:path";
import * as url from "node:url";
import type { ParseResult, ParserPlugin } from "@babel/parser";
import { parse as parseBabel } from "@babel/parser";
import { isCompiledBinary, stripWindowsExtendedLengthPathPrefix } from "@oh-my-pi/pi-utils";
import {
getLegacyPiExtensionCacheDbPath,
isCompiledBinary,
logger,
stripWindowsExtendedLengthPathPrefix,
} from "@oh-my-pi/pi-utils";
import { registerPluginCacheInvalidator } from "../../discovery/helpers";
const IS_COMPILED_BINARY = isCompiledBinary();
@@ -537,6 +543,159 @@ function collectExtensionSpecifierReferences(
return references;
}
const EXTENSION_PARSE_CACHE_SCHEMA_VERSION = 1;
const EXTENSION_PARSE_CACHE_MAX_BYTES = 8 * 1024 * 1024;
const EXTENSION_PARSE_CACHE_MAX_ENTRIES = 10_000;
interface ExtensionSourceAnalysis {
readonly sourceType: "script" | "module";
readonly references: readonly ExtensionSpecifierReference[];
readonly commonJsNamedExports: readonly string[];
readonly commonJsReexportSpecifiers: readonly string[];
}
interface ExtensionParseCacheRow {
source_type: "script" | "module";
references: string;
commonjs_named_exports: string;
commonjs_reexport_specifiers: string;
}
let extensionParseCacheDb: Database | null | undefined;
const extensionSourceAnalysisCache = new Map<string, ExtensionSourceAnalysis>();
function extensionParseCacheKey(source: string, importerPath: string): string {
return `${EXTENSION_PARSE_CACHE_SCHEMA_VERSION}:${path.extname(importerPath).toLowerCase()}:${Bun.hash(source).toString(16)}`;
}
function getExtensionParseCacheDb(): Database | null {
if (extensionParseCacheDb !== undefined) return extensionParseCacheDb;
try {
const cachePath = getLegacyPiExtensionCacheDbPath();
try {
if (fs.statSync(cachePath).size > EXTENSION_PARSE_CACHE_MAX_BYTES) {
fs.rmSync(cachePath, { force: true });
}
} catch {
// A missing or unreadable cache is a cold cache.
}
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
const db = new Database(cachePath, { create: true });
db.run("PRAGMA busy_timeout = 50");
db.run(
"CREATE TABLE IF NOT EXISTS extension_parse_cache (cache_key TEXT PRIMARY KEY, source_type TEXT NOT NULL, references TEXT NOT NULL, commonjs_named_exports TEXT NOT NULL, commonjs_reexport_specifiers TEXT NOT NULL)",
);
extensionParseCacheDb = db;
return db;
} catch (error) {
logger.debug("legacy Pi extension parse cache unavailable", {
error: error instanceof Error ? error.message : String(error),
});
extensionParseCacheDb = null;
return null;
}
}
function parseCachedAnalysis(row: ExtensionParseCacheRow): ExtensionSourceAnalysis | null {
try {
if (row.source_type !== "script" && row.source_type !== "module") return null;
const references = JSON.parse(row.references) as unknown;
const commonJsNamedExports = JSON.parse(row.commonjs_named_exports) as unknown;
const commonJsReexportSpecifiers = JSON.parse(row.commonjs_reexport_specifiers) as unknown;
if (
!Array.isArray(references) ||
!references.every(
reference =>
reference &&
typeof reference === "object" &&
(reference.kind === "import" || reference.kind === "require") &&
typeof reference.specifier === "string" &&
typeof reference.start === "number" &&
typeof reference.end === "number",
) ||
!Array.isArray(commonJsNamedExports) ||
!commonJsNamedExports.every(name => typeof name === "string") ||
!Array.isArray(commonJsReexportSpecifiers) ||
!commonJsReexportSpecifiers.every(specifier => typeof specifier === "string")
) {
return null;
}
return {
sourceType: row.source_type,
references: references as ExtensionSpecifierReference[],
commonJsNamedExports,
commonJsReexportSpecifiers,
};
} catch {
return null;
}
}
function writeExtensionSourceAnalysis(cacheKey: string, analysis: ExtensionSourceAnalysis): void {
void Promise.resolve()
.then(() => {
const db = getExtensionParseCacheDb();
if (!db) return;
db.run(
"INSERT OR REPLACE INTO extension_parse_cache (cache_key, source_type, references, commonjs_named_exports, commonjs_reexport_specifiers) VALUES (?, ?, ?, ?, ?)",
[
cacheKey,
analysis.sourceType,
JSON.stringify(analysis.references),
JSON.stringify(analysis.commonJsNamedExports),
JSON.stringify(analysis.commonJsReexportSpecifiers),
],
);
const count =
db.query<{ count: number }, []>("SELECT count(*) AS count FROM extension_parse_cache").get()?.count ?? 0;
if (count > EXTENSION_PARSE_CACHE_MAX_ENTRIES) {
db.run("DELETE FROM extension_parse_cache");
}
})
.catch(error => {
logger.debug("legacy Pi extension parse cache write failed", {
error: error instanceof Error ? error.message : String(error),
});
});
}
function getExtensionSourceAnalysis(source: string, importerPath: string): ExtensionSourceAnalysis {
const cacheKey = extensionParseCacheKey(source, importerPath);
const memoryCached = extensionSourceAnalysisCache.get(cacheKey);
if (memoryCached) return memoryCached;
const db = getExtensionParseCacheDb();
try {
const row = db
?.query<ExtensionParseCacheRow, [string]>(
"SELECT source_type, references, commonjs_named_exports, commonjs_reexport_specifiers FROM extension_parse_cache WHERE cache_key = ?",
)
.get(cacheKey);
if (row) {
const cached = parseCachedAnalysis(row);
if (cached) {
extensionSourceAnalysisCache.set(cacheKey, cached);
return cached;
}
}
} catch (error) {
logger.debug("legacy Pi extension parse cache read failed", {
error: error instanceof Error ? error.message : String(error),
});
}
const ast = parseExtensionSource(source, importerPath);
const commonJs = collectCommonJsExportAnalysis(ast);
const analysis: ExtensionSourceAnalysis = {
sourceType: ast.program.sourceType,
references: collectExtensionSpecifierReferences(source, importerPath, ast),
...commonJs,
};
extensionSourceAnalysisCache.set(cacheKey, analysis);
writeExtensionSourceAnalysis(cacheKey, analysis);
return analysis;
}
function applySpecifierReplacements(
source: string,
replacements: ReadonlyArray<ExtensionSpecifierReference & { readonly replacement: string }>,
@@ -1003,7 +1162,7 @@ async function rewriteLegacyExtensionSource(
// Compiled mode completes the override map from the build-supplied module
// keys on first use; every rewrite path must see the full map.
await ensureLegacyPiOverridesReady();
const references = collectExtensionSpecifierReferences(source, importerPath);
const references = getExtensionSourceAnalysis(source, importerPath).references;
const replacements: Array<ExtensionSpecifierReference & { replacement: string }> = [];
for (const reference of references) {
if (reference.kind !== "import") continue;
@@ -1448,7 +1607,7 @@ async function isCommonJsModulePath(
return true;
}
const parsedSourceType =
sourceType ?? parseExtensionSource(await Bun.file(modulePath).text(), modulePath).program.sourceType;
sourceType ?? getExtensionSourceAnalysis(await Bun.file(modulePath).text(), modulePath).sourceType;
if (parsedSourceType === "module") {
return false;
}
@@ -1776,7 +1935,7 @@ async function rewriteExtensionSpecifiers(
importerPath: string,
rewriteImports = false,
): Promise<string> {
const references = collectExtensionSpecifierReferences(source, importerPath);
const references = getExtensionSourceAnalysis(source, importerPath).references;
const resolvedSpecifierTargets = new Map<string, string>();
const replacements: Array<ExtensionSpecifierReference & { replacement: string }> = [];
for (const reference of references) {
@@ -1808,7 +1967,7 @@ function rewriteExtensionSpecifiersFromCache(source: string, importerPath: strin
return source;
}
const replacements: Array<ExtensionSpecifierReference & { replacement: string }> = [];
for (const reference of collectExtensionSpecifierReferences(source, importerPath)) {
for (const reference of getExtensionSourceAnalysis(source, importerPath).references) {
const replacement = resolvedSpecifierTargets.get(`${reference.kind}\0${reference.specifier}`);
if (replacement) {
replacements.push({ ...reference, replacement });
@@ -1839,7 +1998,7 @@ async function moduleRequiresNativeAddonUncached(modulePath: string): Promise<bo
} catch {
return false;
}
for (const reference of collectExtensionSpecifierReferences(source, modulePath)) {
for (const reference of getExtensionSourceAnalysis(source, modulePath).references) {
if (reference.kind === "require" && (await resolveExtensionNativeAddon(reference.specifier, modulePath))) {
return true;
}
@@ -2040,18 +2199,18 @@ async function collectExtensionModules(entryRealPath: string): Promise<Extension
continue;
}
modules.set(file, source);
const ast = parseExtensionSource(source, file);
const analysis = getExtensionSourceAnalysis(source, file);
const sourceIsCommonJs = await isGraphOwnedCommonJsModule(
file,
entryRealPath,
ast.program.sourceType,
analysis.sourceType,
inheritedModuleKind,
);
if (sourceIsCommonJs) {
commonJsPaths.add(file);
}
const dir = path.dirname(file);
const references = collectExtensionSpecifierReferences(source, file, ast);
const references = analysis.references;
for (const reference of references) {
const specifier = reference.specifier;
try {
@@ -2233,22 +2392,11 @@ export async function __collectLegacyPiExtensionSourcesForTests(
*/
const COMMONJS_NAMED_EXPORT_IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
function collectCommonJsNamedExports(source: string, modulePath: string, visited = new Set<string>()): string[] {
let realModulePath = modulePath;
try {
realModulePath = fs.realpathSync(modulePath);
} catch {
// The caller's path remains the stable cycle key when realpath fails.
}
if (visited.has(realModulePath)) {
return [];
}
visited.add(realModulePath);
function collectCommonJsExportAnalysis(
ast: ParseResult,
): Pick<ExtensionSourceAnalysis, "commonJsNamedExports" | "commonJsReexportSpecifiers"> {
const names = new Set<string>();
const reexportSpecifiers = new Set<string>();
const ast = parseExtensionSource(source, modulePath);
for (const { node, scope } of collectScopedAstNodes(
ast,
candidate => candidate.type === "CallExpression" || candidate.type === "AssignmentExpression",
@@ -2284,17 +2432,13 @@ function collectCommonJsNamedExports(source: string, modulePath: string, visited
if (node.type !== "AssignmentExpression" || node.operator !== "=") continue;
const left = asAstNode(node.left);
if (left?.type !== "MemberExpression") continue;
const propertyName = staticMemberPropertyName(left);
const object = asAstNode(left.object);
if (propertyName !== null && isUnshadowedExportsTarget(object, scope)) {
if (propertyName !== "default" && COMMONJS_NAMED_EXPORT_IDENTIFIER.test(propertyName)) {
names.add(propertyName);
}
if (propertyName !== "default" && COMMONJS_NAMED_EXPORT_IDENTIFIER.test(propertyName)) names.add(propertyName);
continue;
}
if (!isUncomputedMember(left, "module", "exports") || scopeHasBinding(scope, MODULE_BINDING)) continue;
const right = asAstNode(node.right);
if (right?.type === "ObjectExpression") {
const properties = nodeArray(right, "properties");
@@ -2303,28 +2447,37 @@ function collectCommonJsNamedExports(source: string, modulePath: string, visited
const property = asAstNode(value);
if (!property || (property.type !== "ObjectProperty" && property.type !== "ObjectMethod")) continue;
const name = staticObjectPropertyName(property);
if (name && name !== "default" && COMMONJS_NAMED_EXPORT_IDENTIFIER.test(name)) {
names.add(name);
}
if (name && name !== "default" && COMMONJS_NAMED_EXPORT_IDENTIFIER.test(name)) names.add(name);
}
}
continue;
}
if (isGlobalRequireCall(right, scope)) {
const argument = nodeArgument(right, 0);
if (argument?.type === "StringLiteral" && typeof argument.value === "string") {
if (argument?.type === "StringLiteral" && typeof argument.value === "string")
reexportSpecifiers.add(argument.value);
}
}
}
return { commonJsNamedExports: [...names], commonJsReexportSpecifiers: [...reexportSpecifiers] };
}
function collectCommonJsNamedExports(source: string, modulePath: string, visited = new Set<string>()): string[] {
let realModulePath = modulePath;
try {
realModulePath = fs.realpathSync(modulePath);
} catch {
// The caller's path remains the stable cycle key when realpath fails.
}
if (visited.has(realModulePath)) return [];
visited.add(realModulePath);
const analysis = getExtensionSourceAnalysis(source, modulePath);
const names = new Set(analysis.commonJsNamedExports);
const nativeRequire = createRequire(modulePath);
for (const specifier of reexportSpecifiers) {
for (const specifier of analysis.commonJsReexportSpecifiers) {
try {
const resolved = fs.realpathSync(nativeRequire.resolve(specifier));
const reexportedSource = rewriteExtensionSpecifiersFromCache(fs.readFileSync(resolved, "utf8"), resolved);
for (const name of collectCommonJsNamedExports(reexportedSource, resolved, visited)) {
names.add(name);
}
for (const name of collectCommonJsNamedExports(reexportedSource, resolved, visited)) names.add(name);
} catch {
// Native modules and non-source re-exports do not expose analyzable names.
}
@@ -2021,13 +2021,22 @@ export class StatusLineComponent implements Component {
let percentStart = -1;
let windowStart = -1;
let scaleWidth = gapWidth;
// >100%: usage anchored past the active window (e.g. model switch to a
// smaller window). The bar clamps full, but the embedded label breaks
// past the window label — `──200K─120%` with the percent in error color.
const percentOverflow = pct > 100;
if (embedContext) {
const candidatePercent = formatEmbeddedContextPercent(clampedPct);
const candidatePercent = formatEmbeddedContextPercent(percentOverflow ? pct : clampedPct);
const candidateWindow = formatNumber(ctx.contextWindow);
if (gapWidth >= candidatePercent.length + candidateWindow.length + 4) {
percentLabel = candidatePercent;
windowLabel = candidateWindow;
windowStart = gapWidth - windowLabel.length - 1;
if (percentOverflow) {
percentStart = gapWidth - percentLabel.length;
windowStart = percentStart - 1 - windowLabel.length;
} else {
windowStart = gapWidth - windowLabel.length - 1;
}
scaleWidth = windowStart;
}
}
@@ -2054,7 +2063,7 @@ export class StatusLineComponent implements Component {
}
}
if (percentLabel) {
if (percentLabel && percentStart < 0) {
const maxStart = scaleWidth - percentLabel.length - 1;
const preferredStart = Math.min(maxStart, Math.max(1, usedCount));
const overlapsBoundary = (start: number): boolean => {
@@ -2079,6 +2088,7 @@ export class StatusLineComponent implements Component {
const speculationGlyph = theme.symbol("context.speculation");
const thresholdGlyph = theme.symbol("context.compaction");
const speculationColor = theme.getFgAnsi("muted");
const overflowColor = theme.getFgAnsi("error");
const rawAccentHex = accentHex ?? theme.getColorHex("borderAccent");
const dimmedAccentHex = adjustHsv(rawAccentHex, { s: 0.7, v: 0.75 });
const thresholdColor = getSessionAccentAnsi(dimmedAccentHex) ?? usedColor;
@@ -2089,7 +2099,7 @@ export class StatusLineComponent implements Component {
let color = i < usedCount ? usedColor : unusedColor;
let glyph = horizontal;
if (percentStart >= 0 && i >= percentStart && i < percentStart + percentLabel.length) {
color = usedColor;
color = percentOverflow ? overflowColor : usedColor;
glyph = percentLabel.charAt(i - percentStart);
} else if (i === thresholdIdx) {
color = thresholdColor;
@@ -0,0 +1,421 @@
/**
* OTLP provider registration and signal recording for telemetry export.
*
* Loaded on demand by `./telemetry-export` only when an `OTEL_*` endpoint is
* configured — the OTel SDK + OTLP exporter graph costs ~100ms of module
* evaluation, so it must stay out of default CLI startup.
*
* Only the `http/protobuf` transport is supported — an
* `OTEL_EXPORTER_OTLP*_PROTOCOL` of `grpc` or `http/json` declines rather than
* misrouting protobuf payloads. The exporter line is pinned to the 0.218/2.7
* family validated under Bun; the 1.x OTLP line deadlocks when its
* `req.on("close")` handler fires after a successful export.
*/
import type {
AgentRunCoverage,
AgentRunSummary,
AgentTelemetryConfig,
AgentTelemetryWarning,
ChatUsageEvent,
ToolStatus,
} from "@oh-my-pi/pi-agent-core";
import { logger, postmortem } from "@oh-my-pi/pi-utils";
import {
type Attributes,
type AttributeValue,
type Counter,
context,
type Histogram,
type Meter,
metrics,
} from "@opentelemetry/api";
import { type LogAttributes, logs, type Logger as OtelLogger, SeverityNumber } from "@opentelemetry/api-logs";
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
import { detectResources, envDetector, resourceFromAttributes } from "@opentelemetry/resources";
import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
import type { TelemetrySignalConfig } from "./telemetry-export";
/**
* Periodic flush interval. A long-lived `omp` process (the ACP server is
* spawned once and reused across many turns) would otherwise hold finished
* telemetry until a batch window elapses or the process exits.
*/
const FLUSH_INTERVAL_MS = 30_000;
const SERVICE_NAME = "oh-my-pi";
type OtelLogLevel = "none" | logger.LogLevel;
const LOG_SEVERITY: Record<logger.LogLevel, SeverityNumber> = {
error: SeverityNumber.ERROR,
warn: SeverityNumber.WARN,
info: SeverityNumber.INFO,
debug: SeverityNumber.DEBUG,
};
const LOG_LEVEL_WEIGHT: Record<logger.LogLevel, number> = {
error: 0,
warn: 1,
info: 2,
debug: 3,
};
const TOOL_STATUSES = ["ok", "error", "skipped", "blocked", "timeout", "aborted"] satisfies readonly ToolStatus[];
let traceProvider: NodeTracerProvider | undefined;
let logProvider: LoggerProvider | undefined;
let meterProvider: MeterProvider | undefined;
let metricRecorder: AgentMetricRecorder | undefined;
let otelLogger: OtelLogger | undefined;
let unregisterLogSink: (() => void) | undefined;
/** Whether {@link registerProviders} registered any real OTLP signal provider. */
export function isTelemetryExportEnabled(): boolean {
if (traceProvider) return true;
if (logProvider) return true;
if (meterProvider) return true;
return false;
}
/**
* Merge OTLP metrics/log hooks into an existing agent telemetry config.
*
* The caller still owns content-capture policy, cost estimation, and custom
* attributes. This only appends host-level metrics/log forwarding for the
* providers registered by {@link registerProviders}.
*/
export function createTelemetryExportConfig(
config: AgentTelemetryConfig | undefined,
): AgentTelemetryConfig | undefined {
if (!isTelemetryExportEnabled()) return config;
return {
...config,
onChatUsage: async event => {
await config?.onChatUsage?.(event);
metricRecorder?.recordChatUsage(event);
},
onRunEnd: (summary, coverage) => {
config?.onRunEnd?.(summary, coverage);
metricRecorder?.recordRun(summary, coverage);
emitRunSummaryLog(summary, coverage);
},
onTelemetryWarning: warning => {
config?.onTelemetryWarning?.(warning);
emitTelemetryWarningLog(warning);
},
};
}
/** Register global trace/log/meter providers for the enabled signals. */
export async function registerProviders(signalConfig: TelemetrySignalConfig): Promise<void> {
// `envDetector` parses OTEL_RESOURCE_ATTRIBUTES (percent-decoded, per spec) and
// OTEL_SERVICE_NAME; merged last so both take precedence over the fallback
// service.name — with OTEL_SERVICE_NAME still winning service.name inside the
// detector itself.
const resource = resourceFromAttributes({ "service.name": SERVICE_NAME }).merge(
detectResources({ detectors: [envDetector] }),
);
if (signalConfig.trace) {
const exporter = new OTLPTraceExporter();
traceProvider = new NodeTracerProvider({
resource,
spanProcessors: [new BatchSpanProcessor(exporter)],
});
traceProvider.register({ contextManager: new AsyncLocalStorageContextManager().enable() });
}
if (signalConfig.metric) {
const exporter = new OTLPMetricExporter();
meterProvider = new MeterProvider({
resource,
readers: [new PeriodicExportingMetricReader({ exporter })],
});
metrics.setGlobalMeterProvider(meterProvider);
metricRecorder = new AgentMetricRecorder(metrics.getMeter("@oh-my-pi/pi-coding-agent"));
}
if (signalConfig.log) {
const exporter = new OTLPLogExporter();
logProvider = new LoggerProvider({
resource,
processors: [new BatchLogRecordProcessor({ exporter })],
});
logs.setGlobalLoggerProvider(logProvider);
otelLogger = logProvider.getLogger("@oh-my-pi/pi-coding-agent");
unregisterLogSink = logger.registerLogSink(event => {
emitOtelLog(
event.level,
event.message,
logAttributesFromContext(event.context),
"pi.omp.log",
event.timestamp,
);
});
}
const flushTimer = setInterval(() => {
flushTelemetryExport().catch(() => {});
}, FLUSH_INTERVAL_MS);
flushTimer.unref();
postmortem.register("otel-export", async () => {
clearInterval(flushTimer);
unregisterLogSink?.();
unregisterLogSink = undefined;
const shutdowns: Promise<void>[] = [];
if (traceProvider) shutdowns.push(traceProvider.shutdown());
if (logProvider) shutdowns.push(logProvider.shutdown());
if (meterProvider) shutdowns.push(meterProvider.shutdown());
await Promise.all(shutdowns);
});
}
class AgentMetricRecorder {
readonly #tokenUsage: Histogram<Attributes>;
readonly #chatCostUsd: Counter<Attributes>;
readonly #runs: Counter<Attributes>;
readonly #steps: Counter<Attributes>;
readonly #chatCalls: Counter<Attributes>;
readonly #chatDurationMs: Histogram<Attributes>;
readonly #toolCalls: Counter<Attributes>;
readonly #toolDurationMs: Histogram<Attributes>;
readonly #errors: Counter<Attributes>;
constructor(meter: Meter) {
this.#tokenUsage = meter.createHistogram("gen_ai.client.token.usage", {
description: "Token usage reported by GenAI chat calls.",
unit: "{token}",
});
this.#chatCostUsd = meter.createCounter("pi.omp.agent.chat.cost.estimated_usd", {
description: "Estimated USD cost for completed chat calls.",
unit: "USD",
});
this.#runs = meter.createCounter("pi.omp.agent.runs", {
description: "Completed agent runs.",
unit: "{run}",
});
this.#steps = meter.createCounter("pi.omp.agent.steps", {
description: "Agent loop steps completed inside a run.",
unit: "{step}",
});
this.#chatCalls = meter.createCounter("pi.omp.agent.chat.calls", {
description: "Chat calls completed inside agent runs.",
unit: "{call}",
});
this.#chatDurationMs = meter.createHistogram("pi.omp.agent.chat.duration", {
description: "Total chat latency observed in an agent run.",
unit: "ms",
});
this.#toolCalls = meter.createCounter("pi.omp.agent.tool.calls", {
description: "Tool calls completed inside agent runs.",
unit: "{call}",
});
this.#toolDurationMs = meter.createHistogram("pi.omp.agent.tool.duration", {
description: "Total tool latency observed in an agent run.",
unit: "ms",
});
this.#errors = meter.createCounter("pi.omp.agent.errors", {
description: "Errors observed in chat and tool execution.",
unit: "{error}",
});
}
recordChatUsage(event: ChatUsageEvent): void {
const baseAttrs = metricAttributes({
"gen_ai.operation.name": "chat",
"gen_ai.provider.name": event.provider,
"gen_ai.request.model": event.model,
"gen_ai.response.service_tier": event.serviceTier,
"pi.gen_ai.agent.id": event.agent?.id,
"pi.gen_ai.agent.name": event.agent?.name,
});
this.#recordToken(event.usage.inputTokens, baseAttrs, "input");
this.#recordToken(event.usage.outputTokens, baseAttrs, "output");
this.#recordToken(event.usage.totalTokens, baseAttrs, "total");
this.#recordToken(event.usage.cachedInputTokens, baseAttrs, "cache_read_input");
this.#recordToken(event.usage.cacheWriteTokens, baseAttrs, "cache_write_input");
this.#recordToken(event.usage.reasoningOutputTokens, baseAttrs, "reasoning_output");
if (event.cost && "usd" in event.cost && event.cost.usd > 0) {
this.#chatCostUsd.add(event.cost.usd, baseAttrs);
}
}
recordRun(summary: AgentRunSummary, coverage: AgentRunCoverage): void {
const runAttrs = metricAttributes({
"pi.omp.agent.models_used.count": coverage.modelsUsed.length,
"pi.omp.agent.providers_used.count": coverage.providersUsed.length,
"pi.omp.agent.tools_available.count": coverage.toolsAvailable.length,
"pi.omp.agent.tools_invoked.count": coverage.toolsInvoked.length,
"pi.omp.agent.tools_unused.count": coverage.toolsUnused.length,
});
this.#runs.add(1, runAttrs);
if (summary.stepCount > 0) this.#steps.add(summary.stepCount, runAttrs);
if (summary.chats.totalLatencyMs > 0) this.#chatDurationMs.record(summary.chats.totalLatencyMs, runAttrs);
for (const reason in summary.chats.byStopReason) {
const count = summary.chats.byStopReason[reason];
if (count > 0)
this.#chatCalls.add(count, metricAttributes({ ...runAttrs, "gen_ai.response.finish_reason": reason }));
}
for (const toolName in summary.tools.byName) {
const counters = summary.tools.byName[toolName];
const toolAttrs = metricAttributes({ ...runAttrs, "gen_ai.tool.name": toolName });
if (counters.totalLatencyMs > 0) this.#toolDurationMs.record(counters.totalLatencyMs, toolAttrs);
for (const status of TOOL_STATUSES) {
const count = counters[status];
if (count > 0) this.#toolCalls.add(count, metricAttributes({ ...toolAttrs, "pi.omp.tool.status": status }));
}
}
for (const errorType in summary.errors.byType) {
const count = summary.errors.byType[errorType];
if (count > 0) this.#errors.add(count, metricAttributes({ ...runAttrs, "error.type": errorType }));
}
}
#recordToken(value: number | undefined, baseAttrs: Attributes, tokenType: string): void {
if (!value || value <= 0) return;
this.#tokenUsage.record(value, metricAttributes({ ...baseAttrs, "gen_ai.token.type": tokenType }));
}
}
function metricAttributes(fields: Readonly<Record<string, unknown>>): Attributes {
const out: Attributes = {};
for (const key in fields) {
const value = fields[key];
if (value === undefined || value === null) continue;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
out[key] = value;
continue;
}
const text = String(value);
if (text.length > 0) out[key] = text;
}
return out;
}
function emitRunSummaryLog(summary: AgentRunSummary, coverage: AgentRunCoverage): void {
emitOtelLog(
"info",
"agent run completed",
{
"pi.omp.agent.step_count": summary.stepCount,
"pi.omp.agent.chats.total": summary.chats.total,
"pi.omp.agent.chats.total_latency_ms": summary.chats.totalLatencyMs,
"pi.omp.agent.tools.total": summary.tools.total,
"pi.omp.agent.tools.ok": summary.tools.ok,
"pi.omp.agent.tools.error": summary.tools.error,
"pi.omp.agent.tools.skipped": summary.tools.skipped,
"pi.omp.agent.tools.blocked": summary.tools.blocked,
"pi.omp.agent.tools.timeout": summary.tools.timeout,
"pi.omp.agent.tools.aborted": summary.tools.aborted,
"pi.omp.agent.tools.total_latency_ms": summary.tools.totalLatencyMs,
"pi.omp.agent.usage.input_tokens": summary.usage.inputTokens,
"pi.omp.agent.usage.output_tokens": summary.usage.outputTokens,
"pi.omp.agent.usage.cached_input_tokens": summary.usage.cachedInputTokens,
"pi.omp.agent.usage.cache_write_tokens": summary.usage.cacheWriteTokens,
"pi.omp.agent.usage.reasoning_output_tokens": summary.usage.reasoningOutputTokens,
"pi.omp.agent.usage.total_tokens": summary.usage.totalTokens,
"pi.omp.agent.cost.estimated_usd": summary.cost.estimatedUsd,
"pi.omp.agent.cost.unavailable_reasons": summary.cost.unavailableReasons.join(","),
"pi.omp.agent.errors.total": summary.errors.total,
"pi.omp.agent.coverage.tools_available": coverage.toolsAvailable.join(","),
"pi.omp.agent.coverage.tools_invoked": coverage.toolsInvoked.join(","),
"pi.omp.agent.coverage.tools_unused": coverage.toolsUnused.join(","),
"pi.omp.agent.coverage.models_used": coverage.modelsUsed.join(","),
"pi.omp.agent.coverage.providers_used": coverage.providersUsed.join(","),
},
"pi.omp.agent.run.completed",
);
}
function emitTelemetryWarningLog(warning: AgentTelemetryWarning): void {
const attrs = logAttributesFromContext({
code: warning.code,
error: warning.error,
});
emitOtelLog("warn", warning.message, attrs, "pi.omp.telemetry.warning");
}
function emitOtelLog(
level: logger.LogLevel,
body: string,
attributes: LogAttributes,
eventName: string,
timestamp = new Date(),
): void {
if (!otelLogger) return;
const minLevel = parseOtelLogLevel(process.env.OTEL_LOG_LEVEL);
if (minLevel === "none") return;
if (LOG_LEVEL_WEIGHT[level] > LOG_LEVEL_WEIGHT[minLevel]) return;
otelLogger.emit({
eventName,
timestamp,
observedTimestamp: new Date(),
severityNumber: LOG_SEVERITY[level],
severityText: level.toUpperCase(),
body,
attributes,
context: context.active(),
});
}
function parseOtelLogLevel(raw: string | undefined): OtelLogLevel {
if (!raw) return "info";
switch (raw.trim().toLowerCase()) {
case "none":
return "none";
case "error":
return "error";
case "warn":
case "warning":
return "warn";
case "debug":
return "debug";
default:
return "info";
}
}
function logAttributesFromContext(input: Record<string, unknown> | undefined): LogAttributes {
const out: LogAttributes = { "process.pid": process.pid };
if (!input) return out;
for (const key in input) {
const attr = logAttributeValue(input[key]);
if (attr !== undefined) out[key] = attr;
}
return out;
}
function logAttributeValue(value: unknown): AttributeValue | undefined {
if (value === undefined || value === null) return undefined;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
if (value instanceof Error) {
return `${value.name}: ${value.message}`;
}
try {
const text = JSON.stringify(value);
if (text && text.length > 0) return text;
} catch {
return String(value);
}
return String(value);
}
/** Flush buffered spans, log records, and metrics across all registered providers. */
export async function flushTelemetryExport(): Promise<void> {
const flushes: Promise<void>[] = [];
if (traceProvider) flushes.push(traceProvider.forceFlush());
if (logProvider) flushes.push(logProvider.forceFlush());
if (meterProvider) flushes.push(meterProvider.forceFlush());
await Promise.all(flushes);
}
+38 -404
View File
@@ -3,86 +3,37 @@
*
* oh-my-pi's agent core (`@oh-my-pi/pi-agent-core`) emits OpenTelemetry GenAI
* spans through the global `@opentelemetry/api` tracer, and exposes run-level
* callbacks for metrics/log pipelines. This module registers the OTLP/proto
* trace, log, and metric SDK providers when the standard `OTEL_*` endpoint env
* vars are set so `omp` can be observed by any OTLP collector without vendor
* coupling.
* callbacks for metrics/log pipelines. This module resolves the standard
* `OTEL_*` env contract (endpoint, exporter selection, protocol,
* `OTEL_SDK_DISABLED`) and, only when at least one signal has an OTLP endpoint,
* loads `./telemetry-export-otlp` to register the trace/log/metric providers —
* keeping the OTel SDK + exporter module graph (~100ms) out of default startup.
*
* Only the `http/protobuf` transport is supported — an
* `OTEL_EXPORTER_OTLP*_PROTOCOL` of `grpc` or `http/json` declines rather than
* misrouting protobuf payloads. The exporter line is pinned to the 0.218/2.7
* family validated under Bun; the 1.x OTLP line deadlocks when its
* `req.on("close")` handler fires after a successful export.
* misrouting protobuf payloads.
*/
import type {
AgentRunCoverage,
AgentRunSummary,
AgentTelemetryConfig,
AgentTelemetryWarning,
ChatUsageEvent,
ToolStatus,
} from "@oh-my-pi/pi-agent-core";
import { logger, postmortem } from "@oh-my-pi/pi-utils";
import {
type Attributes,
type AttributeValue,
type Counter,
context,
type Histogram,
type Meter,
metrics,
} from "@opentelemetry/api";
import { type LogAttributes, logs, type Logger as OtelLogger, SeverityNumber } from "@opentelemetry/api-logs";
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
import { detectResources, envDetector, resourceFromAttributes } from "@opentelemetry/resources";
import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
import type { AgentTelemetryConfig } from "@oh-my-pi/pi-agent-core";
import { logger } from "@oh-my-pi/pi-utils";
/**
* Periodic flush interval. A long-lived `omp` process (the ACP server is
* spawned once and reused across many turns) would otherwise hold finished
* telemetry until a batch window elapses or the process exits.
*/
const FLUSH_INTERVAL_MS = 30_000;
const SERVICE_NAME = "oh-my-pi";
type TelemetrySignal = "trace" | "log" | "metric";
type OtelLogLevel = "none" | logger.LogLevel;
interface SignalConfig {
/** Per-signal OTLP export toggles resolved from the `OTEL_*` env contract. */
export interface TelemetrySignalConfig {
readonly trace: boolean;
readonly log: boolean;
readonly metric: boolean;
}
const LOG_SEVERITY: Record<logger.LogLevel, SeverityNumber> = {
error: SeverityNumber.ERROR,
warn: SeverityNumber.WARN,
info: SeverityNumber.INFO,
debug: SeverityNumber.DEBUG,
};
type TelemetrySignal = "trace" | "log" | "metric";
const LOG_LEVEL_WEIGHT: Record<logger.LogLevel, number> = {
error: 0,
warn: 1,
info: 2,
debug: 3,
};
/** Loaded OTLP implementation module; `undefined` until a signal registers. */
interface OtlpExportModule {
registerProviders(signalConfig: TelemetrySignalConfig): Promise<void>;
isTelemetryExportEnabled(): boolean;
createTelemetryExportConfig(config: AgentTelemetryConfig | undefined): AgentTelemetryConfig | undefined;
flushTelemetryExport(): Promise<void>;
}
const TOOL_STATUSES = ["ok", "error", "skipped", "blocked", "timeout", "aborted"] satisfies readonly ToolStatus[];
let traceProvider: NodeTracerProvider | undefined;
let logProvider: LoggerProvider | undefined;
let meterProvider: MeterProvider | undefined;
let metricRecorder: AgentMetricRecorder | undefined;
let otelLogger: OtelLogger | undefined;
let unregisterLogSink: (() => void) | undefined;
let otlp: OtlpExportModule | undefined;
let initPromise: Promise<void> | undefined;
/**
@@ -92,10 +43,7 @@ let initPromise: Promise<void> | undefined;
* disabled.
*/
export function isTelemetryExportEnabled(): boolean {
if (traceProvider) return true;
if (logProvider) return true;
if (meterProvider) return true;
return false;
return otlp?.isTelemetryExportEnabled() ?? false;
}
/**
@@ -103,28 +51,13 @@ export function isTelemetryExportEnabled(): boolean {
*
* The caller still owns content-capture policy, cost estimation, and custom
* attributes. This only appends host-level metrics/log forwarding for the
* providers registered by {@link initTelemetryExport}.
* providers registered by {@link initTelemetryExport}; a passthrough when
* export is disabled.
*/
export function createTelemetryExportConfig(
config: AgentTelemetryConfig | undefined,
): AgentTelemetryConfig | undefined {
if (!isTelemetryExportEnabled()) return config;
return {
...config,
onChatUsage: async event => {
await config?.onChatUsage?.(event);
metricRecorder?.recordChatUsage(event);
},
onRunEnd: (summary, coverage) => {
config?.onRunEnd?.(summary, coverage);
metricRecorder?.recordRun(summary, coverage);
emitRunSummaryLog(summary, coverage);
},
onTelemetryWarning: warning => {
config?.onTelemetryWarning?.(warning);
emitTelemetryWarningLog(warning);
},
};
return otlp ? otlp.createTelemetryExportConfig(config) : config;
}
/**
@@ -133,7 +66,6 @@ export function createTelemetryExportConfig(
* the OTEL kill-switches are engaged), so startup can call it unconditionally.
*/
export async function initTelemetryExport(): Promise<void> {
if (isTelemetryExportEnabled()) return;
if (initPromise) return initPromise;
if (process.env.OTEL_SDK_DISABLED?.trim().toLowerCase() === "true") return;
@@ -141,76 +73,26 @@ export async function initTelemetryExport(): Promise<void> {
const signalConfig = resolveSignalConfig();
if (!signalConfig.trace && !signalConfig.log && !signalConfig.metric) return;
initPromise = registerProviders(signalConfig);
initPromise = (async () => {
// Branch-only: the OTel SDK + OTLP exporter graph loads only when an endpoint is configured.
const impl: OtlpExportModule = await import("./telemetry-export-otlp");
await impl.registerProviders(signalConfig);
otlp = impl;
})();
return initPromise;
}
async function registerProviders(signalConfig: SignalConfig): Promise<void> {
// `envDetector` parses OTEL_RESOURCE_ATTRIBUTES (percent-decoded, per spec) and
// OTEL_SERVICE_NAME; merged last so both take precedence over the fallback
// service.name — with OTEL_SERVICE_NAME still winning service.name inside the
// detector itself.
const resource = resourceFromAttributes({ "service.name": SERVICE_NAME }).merge(
detectResources({ detectors: [envDetector] }),
);
if (signalConfig.trace) {
const exporter = new OTLPTraceExporter();
traceProvider = new NodeTracerProvider({
resource,
spanProcessors: [new BatchSpanProcessor(exporter)],
});
traceProvider.register({ contextManager: new AsyncLocalStorageContextManager().enable() });
}
if (signalConfig.metric) {
const exporter = new OTLPMetricExporter();
meterProvider = new MeterProvider({
resource,
readers: [new PeriodicExportingMetricReader({ exporter })],
});
metrics.setGlobalMeterProvider(meterProvider);
metricRecorder = new AgentMetricRecorder(metrics.getMeter("@oh-my-pi/pi-coding-agent"));
}
if (signalConfig.log) {
const exporter = new OTLPLogExporter();
logProvider = new LoggerProvider({
resource,
processors: [new BatchLogRecordProcessor({ exporter })],
});
logs.setGlobalLoggerProvider(logProvider);
otelLogger = logProvider.getLogger("@oh-my-pi/pi-coding-agent");
unregisterLogSink = logger.registerLogSink(event => {
emitOtelLog(
event.level,
event.message,
logAttributesFromContext(event.context),
"pi.omp.log",
event.timestamp,
);
});
}
const flushTimer = setInterval(() => {
flushTelemetryExport().catch(() => {});
}, FLUSH_INTERVAL_MS);
flushTimer.unref();
postmortem.register("otel-export", async () => {
clearInterval(flushTimer);
unregisterLogSink?.();
unregisterLogSink = undefined;
const shutdowns: Promise<void>[] = [];
if (traceProvider) shutdowns.push(traceProvider.shutdown());
if (logProvider) shutdowns.push(logProvider.shutdown());
if (meterProvider) shutdowns.push(meterProvider.shutdown());
await Promise.all(shutdowns);
});
/**
* Flush buffered spans, log records, and metrics. No-op when export is disabled.
* Hosts embedding the agent can call this at natural boundaries (e.g. the end
* of a turn) so telemetry surfaces promptly rather than on the batch interval.
*/
export async function flushTelemetryExport(): Promise<void> {
if (otlp) await otlp.flushTelemetryExport();
}
function resolveSignalConfig(): SignalConfig {
const signalConfig: SignalConfig = {
function resolveSignalConfig(): TelemetrySignalConfig {
return {
trace: signalEnabled(
"trace",
process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
@@ -230,7 +112,6 @@ function resolveSignalConfig(): SignalConfig {
process.env.OTEL_EXPORTER_OTLP_METRICS_PROTOCOL ?? process.env.OTEL_EXPORTER_OTLP_PROTOCOL,
),
};
return signalConfig;
}
function signalEnabled(
@@ -255,250 +136,3 @@ function signalEnabled(
}
return true;
}
class AgentMetricRecorder {
readonly #tokenUsage: Histogram<Attributes>;
readonly #chatCostUsd: Counter<Attributes>;
readonly #runs: Counter<Attributes>;
readonly #steps: Counter<Attributes>;
readonly #chatCalls: Counter<Attributes>;
readonly #chatDurationMs: Histogram<Attributes>;
readonly #toolCalls: Counter<Attributes>;
readonly #toolDurationMs: Histogram<Attributes>;
readonly #errors: Counter<Attributes>;
constructor(meter: Meter) {
this.#tokenUsage = meter.createHistogram("gen_ai.client.token.usage", {
description: "Token usage reported by GenAI chat calls.",
unit: "{token}",
});
this.#chatCostUsd = meter.createCounter("pi.omp.agent.chat.cost.estimated_usd", {
description: "Estimated USD cost for completed chat calls.",
unit: "USD",
});
this.#runs = meter.createCounter("pi.omp.agent.runs", {
description: "Completed agent runs.",
unit: "{run}",
});
this.#steps = meter.createCounter("pi.omp.agent.steps", {
description: "Agent loop steps completed inside a run.",
unit: "{step}",
});
this.#chatCalls = meter.createCounter("pi.omp.agent.chat.calls", {
description: "Chat calls completed inside agent runs.",
unit: "{call}",
});
this.#chatDurationMs = meter.createHistogram("pi.omp.agent.chat.duration", {
description: "Total chat latency observed in an agent run.",
unit: "ms",
});
this.#toolCalls = meter.createCounter("pi.omp.agent.tool.calls", {
description: "Tool calls completed inside agent runs.",
unit: "{call}",
});
this.#toolDurationMs = meter.createHistogram("pi.omp.agent.tool.duration", {
description: "Total tool latency observed in an agent run.",
unit: "ms",
});
this.#errors = meter.createCounter("pi.omp.agent.errors", {
description: "Errors observed in chat and tool execution.",
unit: "{error}",
});
}
recordChatUsage(event: ChatUsageEvent): void {
const baseAttrs = metricAttributes({
"gen_ai.operation.name": "chat",
"gen_ai.provider.name": event.provider,
"gen_ai.request.model": event.model,
"gen_ai.response.service_tier": event.serviceTier,
"pi.gen_ai.agent.id": event.agent?.id,
"pi.gen_ai.agent.name": event.agent?.name,
});
this.#recordToken(event.usage.inputTokens, baseAttrs, "input");
this.#recordToken(event.usage.outputTokens, baseAttrs, "output");
this.#recordToken(event.usage.totalTokens, baseAttrs, "total");
this.#recordToken(event.usage.cachedInputTokens, baseAttrs, "cache_read_input");
this.#recordToken(event.usage.cacheWriteTokens, baseAttrs, "cache_write_input");
this.#recordToken(event.usage.reasoningOutputTokens, baseAttrs, "reasoning_output");
if (event.cost && "usd" in event.cost && event.cost.usd > 0) {
this.#chatCostUsd.add(event.cost.usd, baseAttrs);
}
}
recordRun(summary: AgentRunSummary, coverage: AgentRunCoverage): void {
const runAttrs = metricAttributes({
"pi.omp.agent.models_used.count": coverage.modelsUsed.length,
"pi.omp.agent.providers_used.count": coverage.providersUsed.length,
"pi.omp.agent.tools_available.count": coverage.toolsAvailable.length,
"pi.omp.agent.tools_invoked.count": coverage.toolsInvoked.length,
"pi.omp.agent.tools_unused.count": coverage.toolsUnused.length,
});
this.#runs.add(1, runAttrs);
if (summary.stepCount > 0) this.#steps.add(summary.stepCount, runAttrs);
if (summary.chats.totalLatencyMs > 0) this.#chatDurationMs.record(summary.chats.totalLatencyMs, runAttrs);
for (const reason in summary.chats.byStopReason) {
const count = summary.chats.byStopReason[reason];
if (count > 0)
this.#chatCalls.add(count, metricAttributes({ ...runAttrs, "gen_ai.response.finish_reason": reason }));
}
for (const toolName in summary.tools.byName) {
const counters = summary.tools.byName[toolName];
const toolAttrs = metricAttributes({ ...runAttrs, "gen_ai.tool.name": toolName });
if (counters.totalLatencyMs > 0) this.#toolDurationMs.record(counters.totalLatencyMs, toolAttrs);
for (const status of TOOL_STATUSES) {
const count = counters[status];
if (count > 0) this.#toolCalls.add(count, metricAttributes({ ...toolAttrs, "pi.omp.tool.status": status }));
}
}
for (const errorType in summary.errors.byType) {
const count = summary.errors.byType[errorType];
if (count > 0) this.#errors.add(count, metricAttributes({ ...runAttrs, "error.type": errorType }));
}
}
#recordToken(value: number | undefined, baseAttrs: Attributes, tokenType: string): void {
if (!value || value <= 0) return;
this.#tokenUsage.record(value, metricAttributes({ ...baseAttrs, "gen_ai.token.type": tokenType }));
}
}
function metricAttributes(fields: Readonly<Record<string, unknown>>): Attributes {
const out: Attributes = {};
for (const key in fields) {
const value = fields[key];
if (value === undefined || value === null) continue;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
out[key] = value;
continue;
}
const text = String(value);
if (text.length > 0) out[key] = text;
}
return out;
}
function emitRunSummaryLog(summary: AgentRunSummary, coverage: AgentRunCoverage): void {
emitOtelLog(
"info",
"agent run completed",
{
"pi.omp.agent.step_count": summary.stepCount,
"pi.omp.agent.chats.total": summary.chats.total,
"pi.omp.agent.chats.total_latency_ms": summary.chats.totalLatencyMs,
"pi.omp.agent.tools.total": summary.tools.total,
"pi.omp.agent.tools.ok": summary.tools.ok,
"pi.omp.agent.tools.error": summary.tools.error,
"pi.omp.agent.tools.skipped": summary.tools.skipped,
"pi.omp.agent.tools.blocked": summary.tools.blocked,
"pi.omp.agent.tools.timeout": summary.tools.timeout,
"pi.omp.agent.tools.aborted": summary.tools.aborted,
"pi.omp.agent.tools.total_latency_ms": summary.tools.totalLatencyMs,
"pi.omp.agent.usage.input_tokens": summary.usage.inputTokens,
"pi.omp.agent.usage.output_tokens": summary.usage.outputTokens,
"pi.omp.agent.usage.cached_input_tokens": summary.usage.cachedInputTokens,
"pi.omp.agent.usage.cache_write_tokens": summary.usage.cacheWriteTokens,
"pi.omp.agent.usage.reasoning_output_tokens": summary.usage.reasoningOutputTokens,
"pi.omp.agent.usage.total_tokens": summary.usage.totalTokens,
"pi.omp.agent.cost.estimated_usd": summary.cost.estimatedUsd,
"pi.omp.agent.cost.unavailable_reasons": summary.cost.unavailableReasons.join(","),
"pi.omp.agent.errors.total": summary.errors.total,
"pi.omp.agent.coverage.tools_available": coverage.toolsAvailable.join(","),
"pi.omp.agent.coverage.tools_invoked": coverage.toolsInvoked.join(","),
"pi.omp.agent.coverage.tools_unused": coverage.toolsUnused.join(","),
"pi.omp.agent.coverage.models_used": coverage.modelsUsed.join(","),
"pi.omp.agent.coverage.providers_used": coverage.providersUsed.join(","),
},
"pi.omp.agent.run.completed",
);
}
function emitTelemetryWarningLog(warning: AgentTelemetryWarning): void {
const attrs = logAttributesFromContext({
code: warning.code,
error: warning.error,
});
emitOtelLog("warn", warning.message, attrs, "pi.omp.telemetry.warning");
}
function emitOtelLog(
level: logger.LogLevel,
body: string,
attributes: LogAttributes,
eventName: string,
timestamp = new Date(),
): void {
if (!otelLogger) return;
const minLevel = parseOtelLogLevel(process.env.OTEL_LOG_LEVEL);
if (minLevel === "none") return;
if (LOG_LEVEL_WEIGHT[level] > LOG_LEVEL_WEIGHT[minLevel]) return;
otelLogger.emit({
eventName,
timestamp,
observedTimestamp: new Date(),
severityNumber: LOG_SEVERITY[level],
severityText: level.toUpperCase(),
body,
attributes,
context: context.active(),
});
}
function parseOtelLogLevel(raw: string | undefined): OtelLogLevel {
if (!raw) return "info";
switch (raw.trim().toLowerCase()) {
case "none":
return "none";
case "error":
return "error";
case "warn":
case "warning":
return "warn";
case "debug":
return "debug";
default:
return "info";
}
}
function logAttributesFromContext(input: Record<string, unknown> | undefined): LogAttributes {
const out: LogAttributes = { "process.pid": process.pid };
if (!input) return out;
for (const key in input) {
const attr = logAttributeValue(input[key]);
if (attr !== undefined) out[key] = attr;
}
return out;
}
function logAttributeValue(value: unknown): AttributeValue | undefined {
if (value === undefined || value === null) return undefined;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
if (value instanceof Error) {
return `${value.name}: ${value.message}`;
}
try {
const text = JSON.stringify(value);
if (text && text.length > 0) return text;
} catch {
return String(value);
}
return String(value);
}
/**
* Flush buffered spans, log records, and metrics. No-op when export is disabled.
* Hosts embedding the agent can call this at natural boundaries (e.g. the end
* of a turn) so telemetry surfaces promptly rather than on the batch interval.
*/
export async function flushTelemetryExport(): Promise<void> {
const flushes: Promise<void>[] = [];
if (traceProvider) flushes.push(traceProvider.forceFlush());
if (logProvider) flushes.push(logProvider.forceFlush());
if (meterProvider) flushes.push(meterProvider.forceFlush());
await Promise.all(flushes);
}
+112 -24
View File
@@ -1,5 +1,6 @@
import * as path from "node:path";
import { getLastChangelogVersionPath, isEnoent, logger } from "@oh-my-pi/pi-utils";
import type { BunFile } from "bun";
import bundledChangelogPath from "../../CHANGELOG.md" with { type: "file" };
import type { SettingValue } from "../config/settings";
@@ -197,6 +198,88 @@ function parseChangelogContent(content: string): ChangelogEntry[] {
return entries;
}
async function parseStartupChangelog(
changelogPath: string | undefined,
lastVersion: ChangelogEntry,
): Promise<{ entries: ChangelogEntry[]; totalUnseenEntries: number }> {
if (changelogPath) {
try {
return await parseStartupChangelogFile(Bun.file(changelogPath), lastVersion);
} catch (error) {
if (!isEnoent(error)) {
logger.error(`Warning: Could not parse changelog: ${error}`);
}
}
}
return parseStartupChangelogFile(
Bun.file(resolveBundledChangelogPath(bundledChangelogPath, import.meta.url)),
lastVersion,
);
}
async function parseStartupChangelogFile(
file: BunFile,
lastVersion: ChangelogEntry,
): Promise<{ entries: ChangelogEntry[]; totalUnseenEntries: number }> {
const entries: ChangelogEntry[] = [];
let currentVersion: ChangelogEntry | undefined;
let currentLines: string[] | undefined;
let totalUnseenEntries = 0;
const finishCurrentEntry = () => {
if (currentVersion && currentLines) {
entries.push({ ...currentVersion, content: currentLines.join("\n").trim() });
}
currentVersion = undefined;
currentLines = undefined;
};
const processLine = (line: string): boolean => {
if (!line.startsWith("## ")) {
currentLines?.push(line);
return false;
}
finishCurrentEntry();
const versionMatch = line.match(/##\s+\[?(\d+)\.(\d+)\.(\d+)\]?/);
if (!versionMatch) return false;
const version = {
major: Number.parseInt(versionMatch[1], 10),
minor: Number.parseInt(versionMatch[2], 10),
patch: Number.parseInt(versionMatch[3], 10),
content: "",
};
if (compareChangelogEntries(version, lastVersion) <= 0) return true;
totalUnseenEntries++;
if (entries.length < RECENT_CHANGELOG_ENTRY_LIMIT) {
currentVersion = version;
currentLines = [line];
}
return false;
};
const decoder = new TextDecoder();
let pending = "";
const chunkSize = 32 * 1024;
for (let start = 0; start < file.size; start += chunkSize) {
const end = Math.min(start + chunkSize, file.size);
pending += decoder.decode(await file.slice(start, end).arrayBuffer(), { stream: end < file.size });
let newlineIndex = pending.indexOf("\n");
while (newlineIndex !== -1) {
if (processLine(pending.slice(0, newlineIndex))) {
return { entries, totalUnseenEntries };
}
pending = pending.slice(newlineIndex + 1);
newlineIndex = pending.indexOf("\n");
}
}
if (pending && !processLine(pending + decoder.decode())) {
finishCurrentEntry();
}
return { entries, totalUnseenEntries };
}
/**
* Compare changelog entries by their parsed version parts.
* Returns: -1 if v1 < v2, 0 if v1 === v2, 1 if v1 > v2
@@ -264,6 +347,32 @@ export function renderChangelogEntries(
return { markdown: markdown.slice(0, low) + suffix, truncated: true };
}
function selectStartupChangelogEntries(
newEntries: ChangelogEntry[],
totalUnseenEntries: number,
): StartupChangelogSelection {
if (newEntries.length === 0) {
return emptyStartupSelection(false);
}
const rendered = renderChangelogEntries(newEntries, {
maxBytes: STARTUP_CHANGELOG_MAX_BYTES,
truncationHint: STARTUP_CHANGELOG_FULL_HINT,
oldestFirst: false,
});
const summary = summarizeChangelogEntries(newEntries);
const latestEntry = newEntries[0];
return {
markdown: rendered.markdown,
persistCurrentVersion: true,
truncated: rendered.truncated,
selectedEntries: newEntries.length,
totalUnseenEntries,
latestVersion: latestEntry ? `${latestEntry.major}.${latestEntry.minor}.${latestEntry.patch}` : undefined,
...summary,
};
}
/**
* Select bounded release notes for interactive startup.
*/
@@ -282,27 +391,7 @@ export function selectStartupChangelog(
}
const allNewEntries = getNewEntries(entries, markerVersion);
const newEntries = allNewEntries.slice(0, RECENT_CHANGELOG_ENTRY_LIMIT);
if (newEntries.length === 0) {
return emptyStartupSelection(false);
}
const rendered = renderChangelogEntries(newEntries, {
maxBytes: STARTUP_CHANGELOG_MAX_BYTES,
truncationHint: STARTUP_CHANGELOG_FULL_HINT,
oldestFirst: false,
});
const summary = summarizeChangelogEntries(newEntries);
const latestEntry = newEntries[0];
return {
markdown: rendered.markdown,
persistCurrentVersion: true,
truncated: rendered.truncated,
selectedEntries: newEntries.length,
totalUnseenEntries: allNewEntries.length,
latestVersion: latestEntry ? `${latestEntry.major}.${latestEntry.minor}.${latestEntry.patch}` : undefined,
...summary,
};
return selectStartupChangelogEntries(allNewEntries.slice(0, RECENT_CHANGELOG_ENTRY_LIMIT), allNewEntries.length);
}
/**
@@ -334,9 +423,8 @@ export async function resolveStartupChangelogForDisplay(options: {
}
return undefined;
}
const entries = await parseChangelog(options.changelogPath);
const startupChangelog = selectStartupChangelog(entries, lastVersion, options.currentVersion);
const { entries, totalUnseenEntries } = await parseStartupChangelog(options.changelogPath, parsedLastVersion);
const startupChangelog = selectStartupChangelogEntries(entries, totalUnseenEntries);
if (startupChangelog.persistCurrentVersion) {
await writeLastChangelogVersion(options.currentVersion, options.agentDir);
}
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { create, fromBinary } from "@bufbuild/protobuf";
import { create, fromBinary } from "@oh-my-pi/pi-catalog/discovery/protobuf";
import { type } from "@oh-my-pi/omptype";
import type { AgentEvent, AgentTool, AgentToolContext } from "@oh-my-pi/pi-agent-core";
import { type BlockState, handleServerMessage, type ToolCallState } from "@oh-my-pi/pi-ai/providers/cursor";
@@ -17,7 +17,7 @@ import {
McpArgsSchema,
ReadArgsSchema,
ShellArgsSchema,
} from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
} from "@oh-my-pi/pi-catalog/discovery/cursor-proto";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { CursorExecHandlers } from "@oh-my-pi/pi-coding-agent/cursor";
import {
@@ -401,6 +401,32 @@ describe("legacy Pi Babel AST behavior baseline", () => {
}
});
test("keeps mixed import and require rewrites byte-identical after a cached analysis", async () => {
const source = [
'import value from "tracked-dep";',
'export { value as named } from "tracked-dep";',
'const required = require("tracked-dep");',
].join("\n");
const expected = [
`import value from ${JSON.stringify(importTarget)};`,
`export { value as named } from ${JSON.stringify(importTarget)};`,
`const required = require(${JSON.stringify(requireTarget)});`,
].join("\n");
expect(await __rewriteLegacyExtensionSourceForTests(source, rewriteImporter)).toBe(expected);
expect(await __rewriteLegacyExtensionSourceForTests(source, rewriteImporter)).toBe(expected);
});
test("uses a fresh analysis when extension source content changes", async () => {
const original = 'import value from "tracked-dep";';
const changed = 'const value = require("tracked-dep");';
expect(await __rewriteLegacyExtensionSourceForTests(original, rewriteImporter)).toBe(
`import value from ${JSON.stringify(importTarget)};`,
);
expect(await __rewriteLegacyExtensionSourceForTests(changed, rewriteImporter)).toBe(
`const value = require(${JSON.stringify(requireTarget)});`,
);
});
test("discovers exact CommonJS named exports with Babel binding semantics", async () => {
for (const testCase of commonJsCases) {
const actual = await loadCommonJsCase(testCase);
@@ -364,6 +364,36 @@ describe("StatusLineComponent context breakdown", () => {
settings.clearOverride("statusLine.preset");
}
});
it("embedded overflow (>100%) breaks the raw percent past the window label in error color", () => {
const { session } = makeSession({
messages: [userMessage("hi"), assistantMessage("done")],
contextWindow: 200_000,
usage: { tokens: 240_000, contextWindow: 200_000, percent: 120 },
});
settings.override("statusLine.preset", "custom");
settings.override("statusLine.leftSegments", ["pi", "context_pct"]);
settings.override("statusLine.rightSegments", ["context_total", "session_name"]);
settings.override("statusLine.contextLine", "embedded");
try {
const comp = new StatusLineComponent(session);
const border = comp.getTopBorder(120);
const plain = border.content.replaceAll(/\x1b\[[0-9;]*m/g, "");
const windowIndex = plain.indexOf("200K");
const percentIndex = plain.indexOf("120%");
expect(border.width).toBe(120);
expect(windowIndex).toBeGreaterThanOrEqual(0);
expect(percentIndex).toBeGreaterThan(windowIndex);
// The clamped label must not render alongside the overflow one.
expect(plain).not.toContain("100%");
expect(border.content).toContain(`${theme.getFgAnsi("error")}120%`);
} finally {
settings.clearOverride("statusLine.contextLine");
settings.clearOverride("statusLine.rightSegments");
settings.clearOverride("statusLine.leftSegments");
settings.clearOverride("statusLine.preset");
}
});
it("uses semantic Nerd Font markers for async speculation and compaction boundaries", async () => {
const { session } = makeSession({
messages: [userMessage("hi"), assistantMessage("done")],
@@ -11,6 +11,11 @@ let tempDir: string;
let testSettings: Settings;
const GENERATED_TYPESCRIPT = "// Code generated by sqlc. DO NOT EDIT.\n\nexport const foo = 1;";
const CATALOG_PROTOCOL_DECLARATIONS = [
path.resolve(import.meta.dir, "../../../catalog/src/discovery/cursor-proto.ts"),
path.resolve(import.meta.dir, "../../../catalog/src/discovery/devin-proto.ts"),
];
beforeAll(async () => {
resetSettingsForTest();
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "auto-gen-guard-"));
@@ -80,6 +85,12 @@ describe("assertEditableFile", () => {
await expect(assertEditableFile(filePath, undefined, testSettings)).rejects.toBeInstanceOf(ToolError);
});
it("blocks generated catalog protocol declarations", async () => {
for (const filePath of CATALOG_PROTOCOL_DECLARATIONS) {
await expect(assertEditableFile(filePath, undefined, testSettings)).rejects.toBeInstanceOf(ToolError);
}
});
it("allows normal files", async () => {
const filePath = path.join(tempDir, "normal.ts");
await Bun.write(filePath, "// Regular source file\nexport const foo = 1;");