Skip to content

feat(telemetry): setup client with logging sink - #1846

Merged
Hweinstock merged 6 commits into
aws:refactorfrom
Hweinstock:feat/telemetry-client
Jul 28, 2026
Merged

feat(telemetry): setup client with logging sink#1846
Hweinstock merged 6 commits into
aws:refactorfrom
Hweinstock:feat/telemetry-client

Conversation

@Hweinstock

@Hweinstock Hweinstock commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

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

  • added tests on the client.
  • verified e2e by inspecting the logs on both executable and node build using the following:
    run any command then
> cat ~/.agentcore/logs/output-2026-07-27.log | tail -n 1 | jq
{
  "level": "info",
  "msg": "recording telemetry",
  "time": 1785192842835,
  "cliSessionId": "d1b9e38c-2ec0-4062-9b04-a2034c6bebbf",
  "module": "loggingSink",
  "metricName": "cli.command_run",
  "metricValue": 25,
  "metricAttributes": {
    "service.name": "agentcore-cli",
    "service.version": "0.0.0",
    "agentcore-cli.installation_id": "42edb4ee-1d41-42cf-96ab-195b0356e05a",
    "agentcore-cli.session_id": "d1b9e38c-2ec0-4062-9b04-a2034c6bebbf",
    "os.type": "Linux",
    "os.version": "6.12.94-123.190.amzn2023.x86_64",
    "host.arch": "x64",
    "node.version": "v24.3.0",
    "exit_reason": "success"
  }
}

also failures shows up as failure under exit_reason.

Notes

  • version requires some more effort to wire up, since we can no longer read the package.json when in an executable. Going to create a follow-up task for this.

@github-actions github-actions Bot added the agentcore-harness-reviewing AgentCore Harness review in progress label Jul 27, 2026
@codecov-commenter

codecov-commenter commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.00%. Comparing base (0a43f48) to head (e561a2f).
⚠️ Report is 1 commits behind head on refactor.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Hweinstock
Hweinstock force-pushed the feat/telemetry-client branch from eef3fe7 to 3268b99 Compare July 27, 2026 23:10
@github-actions github-actions Bot removed the agentcore-harness-reviewing AgentCore Harness review in progress label Jul 27, 2026
@Hweinstock
Hweinstock marked this pull request as ready for review July 27, 2026 23:19
@Hweinstock
Hweinstock marked this pull request as draft July 27, 2026 23:26
@Hweinstock
Hweinstock force-pushed the feat/telemetry-client branch from 3268b99 to ca1e62a Compare July 27, 2026 23:33
Comment thread src/telemetry/client.tsx

private getMetricSinks(): MetricSink[] {
if (this.metricSinks !== undefined) return this.metricSinks;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will be async once we add fs sink for audit, so following lazy init pattern from attributes below.

Comment thread src/telemetry/client.tsx
"os.type": os.type(),
"os.version": os.release(),
"host.arch": os.arch(),
"node.version": process.version,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Hweinstock
Hweinstock marked this pull request as ready for review July 27, 2026 23:37
Comment thread src/index.ts
globalConfigAccessor,
});

const commandRunTelemetryRecorder = new TelemetryAttributesRecorder("cli.command_run", {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is what we can pass down the context (will wire up in a future PR).

@Hweinstock
Hweinstock force-pushed the feat/telemetry-client branch from ca1e62a to 5031ae9 Compare July 28, 2026 03:27

@aidandaly24 aidandaly24 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/telemetry/client.tsx
private getMetricSinks(): MetricSink[] {
if (this.metricSinks !== undefined) return this.metricSinks;

this.metricSinks = [new LoggingSink({ logger: this.logger.child({ module: "loggingSink" }) })];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"
# 1

Shouldn't the resolved default sink list be empty when telemetry is disabled?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/telemetry/types.tsx
// value describes duration (ms) of the command
valueSchema: z.number().min(0),
},
} satisfies Record<string, { attributeSchema: z.ZodType; valueSchema: z.ZodType }>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah good question, I was planning to add that back once I get into defining the metric schemas.

Comment thread src/telemetry/types.tsx Outdated
emit<TMetricName extends MetricName>(
metricName: TMetricName,
metricValue: ValueOf<TMetricName>,
attributes: Partial<AttributesOf<TMetricName>>,

@aidandaly24 aidandaly24 Jul 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed I really like that system

Comment thread src/telemetry/types.tsx
*/
export interface MetricSink {
/** Send data to the given metric sink **/
send(metricName: string, value: number, attributes: Record<string, string | number>): void;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why isn't MetricSink.send() not returning a promise like shutdown?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread src/telemetry/types.tsx Outdated
},
} satisfies Record<string, { attributeSchema: z.ZodType; valueSchema: z.ZodType }>;

export type MetricName = keyof typeof METRICS & string;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Redundant check—METRICS already enforces that the key is a string.

Comment thread src/telemetry/types.tsx Outdated
send(metricName: string, value: number, attributes: Record<string, string | number>): void;
/** Flush and close the given metric sink **/
shutdown(): Promise<void>;
name: string;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is a interface, lets make this a getter function.

Comment thread src/telemetry/recorder.tsx Outdated
private attributes: Partial<AttributesOf<TMetricName>>;

constructor(
_metricName: TMetricName,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We never store this. It would be nice to store this in a private variable for future middleware use.

@Hweinstock
Hweinstock merged commit 21353dd into aws:refactor Jul 28, 2026
8 checks passed
@Hweinstock
Hweinstock deleted the feat/telemetry-client branch July 28, 2026 22:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants