feat(telemetry): setup client with logging sink - #1846
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## refactor #1846 +/- ##
============================================
+ Coverage 94.91% 95.00% +0.08%
============================================
Files 154 160 +6
Lines 7455 7581 +126
============================================
+ Hits 7076 7202 +126
Misses 379 379 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
eef3fe7 to
3268b99
Compare
3268b99 to
ca1e62a
Compare
|
|
||
| private getMetricSinks(): MetricSink[] { | ||
| if (this.metricSinks !== undefined) return this.metricSinks; | ||
|
|
There was a problem hiding this comment.
this will be async once we add fs sink for audit, so following lazy init pattern from attributes below.
| "os.type": os.type(), | ||
| "os.version": os.release(), | ||
| "host.arch": os.arch(), | ||
| "node.version": process.version, |
There was a problem hiding this comment.
if I run this via bun, I get the wrong value here since bun hard-codes it for compatibility. However, when using the build for node, it does return the correct version.
| globalConfigAccessor, | ||
| }); | ||
|
|
||
| const commandRunTelemetryRecorder = new TelemetryAttributesRecorder("cli.command_run", { |
There was a problem hiding this comment.
this is what we can pass down the context (will wire up in a future PR).
ca1e62a to
5031ae9
Compare
aidandaly24
left a comment
There was a problem hiding this comment.
Overall this looks good to me and clean. A few comments, let me know what you think. I wasn't sure on the exact decisions regarding the telemetry preference and if we always want the telemetry LoggingSink.
| private getMetricSinks(): MetricSink[] { | ||
| if (this.metricSinks !== undefined) return this.metricSinks; | ||
|
|
||
| this.metricSinks = [new LoggingSink({ logger: this.logger.child({ module: "loggingSink" }) })]; |
There was a problem hiding this comment.
It seems the default Logging sink is created and invoked still without resolving the telemetry preference. The persisted-config case also records again from a second CLI process, so this is not limited to the command that changes the setting.
CONFIG_HOME="$(mktemp -d /tmp/pr1846-config-test.XXXXXX)"
ENV_HOME="$(mktemp -d /tmp/pr1846-env-test.XXXXXX)"
# Persist the opt-out. This currently records one telemetry entry.
HOME="$CONFIG_HOME" bun run src/index.ts config telemetry.enabled false
CONFIG_LOG="$(find "$CONFIG_HOME/.agentcore/logs" -name 'output-*.log' -print -quit)"
rg -c '"msg":"recording telemetry"' "$CONFIG_LOG"
# 1
# Start another process with the persisted opt-out. It records another entry.
HOME="$CONFIG_HOME" bun run src/index.ts config telemetry.enabled
rg -c '"msg":"recording telemetry"' "$CONFIG_LOG"
# 2
# The environment override is also ignored.
HOME="$ENV_HOME" AGENTCORE_TELEMETRY_DISABLED=1 bun run src/index.ts config telemetry.enabled
ENV_LOG="$(find "$ENV_HOME/.agentcore/logs" -name 'output-*.log' -print -quit)"
rg -c '"msg":"recording telemetry"' "$ENV_LOG"
# 1Shouldn't the resolved default sink list be empty when telemetry is disabled?
There was a problem hiding this comment.
I left it on because I think we want users to be able to audit telemetry without enabling it sent to the collector. I.e. maybe i disabled it, but I'm curious what would be sent.
I do think we should disable logging when its not being sent or auditted (since its misleading), but I'll address that when I add the next sink.
| // value describes duration (ms) of the command | ||
| valueSchema: z.number().min(0), | ||
| }, | ||
| } satisfies Record<string, { attributeSchema: z.ZodType; valueSchema: z.ZodType }>; |
There was a problem hiding this comment.
From what I could tell the existing implementation defines safeSchema in src/cli/telemetry/schemas/common-shapes.ts to prevent command attributes from containing free-form strings. For example, InvokeAttrs records has_session_id: z.boolean() rather than the session ID itself. This accepts any z.ZodType. I think the safeSchema was probably helpfu so that the privacy constraint remained enforced by the type system, what do you think?
There was a problem hiding this comment.
yeah good question, I was planning to add that back once I get into defining the metric schemas.
| emit<TMetricName extends MetricName>( | ||
| metricName: TMetricName, | ||
| metricValue: ValueOf<TMetricName>, | ||
| attributes: Partial<AttributesOf<TMetricName>>, |
There was a problem hiding this comment.
nit: emit accepts partial attributes even though it immediately validates them against a schema with required fields. I tested this with {} it passes typechecking, but runtime validation rejects it, the sink receives nothing, and the error is reduced to an internal warning. The recorder can accumulate partial attributes internally, the final emit contract should probably require a complete AttributesOf<TMetricName> or a validated complete object. What do you think?
There was a problem hiding this comment.
yeah I went back and forth on this. The challenge is the recorder doesn't know when its attributes are no longer partial. So making this non-partial would require callers of getData on the recorder to be validating the shape, which feels like the job of the telemetry module.
However, I agree the type signatures are awkward. I'm inclined to move this around so that we explicitly end the recorder lifespan (at which point we validate its accumulated attributes), and get the validated data. Then the telemetry client isn't responsible for any validation, and trusts the type signature.
This is kind of similar to what the built-in span support looks like in otel.
| import type { Logger } from "../logging"; | ||
| import type { MetricSink } from "./types"; | ||
|
|
||
| type LoggingSinkConfig = { |
There was a problem hiding this comment.
compliment: I love how you decided to make a config object instead of just passing logger. This allows to add more parameters if we need to.
There was a problem hiding this comment.
Agreed I really like that system
| */ | ||
| export interface MetricSink { | ||
| /** Send data to the given metric sink **/ | ||
| send(metricName: string, value: number, attributes: Record<string, string | number>): void; |
There was a problem hiding this comment.
Why isn't MetricSink.send() not returning a promise like shutdown?
There was a problem hiding this comment.
This is inherited from how otel does it with their sdk client where emitting/sending data is non-blocking, and batched for us in the background. Ideally emit would be non-async as well, but we need to read from globalConfig (on first invoke)
| }, | ||
| } satisfies Record<string, { attributeSchema: z.ZodType; valueSchema: z.ZodType }>; | ||
|
|
||
| export type MetricName = keyof typeof METRICS & string; |
There was a problem hiding this comment.
Nit: Redundant check—METRICS already enforces that the key is a string.
| send(metricName: string, value: number, attributes: Record<string, string | number>): void; | ||
| /** Flush and close the given metric sink **/ | ||
| shutdown(): Promise<void>; | ||
| name: string; |
There was a problem hiding this comment.
Since this is a interface, lets make this a getter function.
| private attributes: Partial<AttributesOf<TMetricName>>; | ||
|
|
||
| constructor( | ||
| _metricName: TMetricName, |
There was a problem hiding this comment.
We never store this. It would be nice to store this in a private variable for future middleware use.
Problem
telemetry is not existent in this new cli.
Solution
We scaffold the telemetry implementation with three core pieces:
MetricSink: the core abstraction for a destination of data. This could be logs, fs, or the collector. Allows us to decouple transport from orchestration logic.TelemetryClient: the orchestration layer on top of metric sinks that validates metric shapes, and sends the data to the configured destinations.TelemetryAttributesRecorder: a strongly typed object builder that allows us to set attributes later in the program execution. In the future we can pass this down in the context, and populate it in the handlers. We'll also want some middleware to populate common fields like command and global flags.Testing
run any command then
also failures shows up as failure under exit_reason.
Notes