Documentation
¶
Index ¶
- Constants
- func GRPCServerOptions(cfg Config) []grpc.ServerOption
- func RegisterServer(s *grpc.Server, p *Processor)
- type Config
- type ContextObserver
- type DispatchObserver
- type DispatchedSessionEnvelope
- type Dispatcher
- func (d *Dispatcher) Dispatch(ctx context.Context, env TurnEnvelope)
- func (d *Dispatcher) RecordDrop(provider string, reason DropReason, requestID string)
- func (d *Dispatcher) RecordDropContext(provider string, reason DropReason, requestID string, ctx OutcomeContext)
- func (d *Dispatcher) SetObserver(o Observer)
- type DropReason
- type Metrics
- func (m *Metrics) AsObserver() Observer
- func (m *Metrics) Handler() http.Handler
- func (m *Metrics) LargeTurnThreshold() int
- func (m *Metrics) ObserveAccepted(provider string)
- func (m *Metrics) ObserveBodyBytes(provider, side string, bytes int)
- func (m *Metrics) ObserveBodyBytesByOutcome(provider, side, outcome, reason string, bytes int)
- func (m *Metrics) ObserveDispatchLatency(provider string, seconds float64)
- func (m *Metrics) ObserveDrop(provider string, reason DropReason)
- func (m *Metrics) ObserveRawResponseAttached(provider, shape string)
- func (m *Metrics) ObserveRawResponseFallback(provider, reason string)
- func (m *Metrics) ObserveRawResponseSkipped(provider, reason string)
- func (m *Metrics) ObserveReducerEmpty(provider, contentType string, upstreamStatus int)
- func (m *Metrics) ObserveRequestContentLength(provider string, bytes int64)
- func (m *Metrics) ObserveRequestContentLengthUnknown(provider string)
- func (m *Metrics) ObserveResponseDecodeSalvaged(provider, encoding string, messageStopSeen bool)
- func (m *Metrics) ObserveResponseDecoded(encoding, result string)
- func (m *Metrics) ObserveSSEChunks(provider string, n int)
- func (m *Metrics) ObserveTerminal(provider, outcome, reason string, ctx OutcomeContext)
- func (m *Metrics) ObserveTerminalDuration(provider, outcome, reason string, seconds float64)
- func (m *Metrics) ObserveTurnDuration(provider string, seconds float64)
- func (m *Metrics) ObserveTurnSize(provider string, respBytes int)
- func (m *Metrics) Registry() *prometheus.Registry
- func (m *Metrics) SetBuildInfo(version, commit string)
- func (m *Metrics) SetInflight(n int)
- func (m *Metrics) SetLargeTurnThreshold(bytes int)
- type Observer
- type OutcomeContext
- type Processor
- type RawLaneObserver
- type RawResponseMode
- type TurnEnvelope
- type TurnMeta
Constants ¶
const ( DropUpstreamStatus = DropReason(capture.DropUpstreamStatus) DropNonTurnRequest = DropReason(capture.DropNonTurnRequest) DropRequestDecode = DropReason(capture.DropRequestDecode) DropEmptyResponse = DropReason(capture.DropEmptyResponse) DropUnknownProvider = DropReason(capture.DropUnknownProvider) DropResponseDecode = DropReason(capture.DropResponseDecode) DropReducerError = DropReason(capture.DropReducerError) )
The capture-policy half. These say what makes a turn capturable at all, so they belong to every implementation of tapes capture and not to this one: two capture paths that disagree about any of them record different sessions from identical traffic.
The strings are therefore NOT declared here. They are conversions of the specified vocabulary in pkg/capture, so this adapter reads the contract instead of restating it — restating it is how the last capture contract drifted while both copies stayed green. Behaviour and metric labels are unchanged: these are the same strings they have always been, sourced from the place that now owns them.
const DefaultLargeTurnThreshold = 4 * 1024 * 1024
Metrics is the Prometheus surface for tapes-extproc. Each Processor owns its own registry so tests can scrape in isolation; the cmd-level wiring mounts /metrics on the existing HTTP mux. DefaultLargeTurnThreshold is the response-body size at which a turn is counted as "large". Chosen to sit above the expected p99 for normal agentic traffic (~1 MB) but below pathological tool-use turns. The threshold is observability, not a cap — turns above it are still captured and dispatched as usual.
It is deliberately NOT capture policy, and that was a decision rather than an omission — see fixtures/drop-reason/README.md, which records it alongside the drop-reason taxonomy because it is the same kind of question. It gates a counter and nothing else: two builds that disagree about it capture, dispatch and store byte-identical turns, and differ only in one deployment's sizing dashboard. That is also why it is settable per deployment. A threshold that dropped or truncated a turn would have to be contract; this one is an observability knob wearing a policy's shape.
Variables ¶
This section is empty.
Functions ¶
func GRPCServerOptions ¶ added in v0.35.0
func GRPCServerOptions(cfg Config) []grpc.ServerOption
GRPCServerOptions builds the server options main wires into grpc.NewServer; the recv limit default is justified at defaultGRPCMaxRecvBytes.
func RegisterServer ¶
RegisterServer installs p on the given gRPC server.
Types ¶
type Config ¶
type Config struct {
// IngestURL is the base URL for the tapes ingest server.
IngestURL string
// ListenAddr is the gRPC TCP listen address.
ListenAddr string
// MetricsAddr is the HTTP metrics/health listen address.
MetricsAddr string
// ProviderMapFile is the path to the provider mapping YAML file.
ProviderMapFile string
// MaxInflight is the dispatch semaphore capacity.
MaxInflight int
// GRPCMaxRecvBytes is the gRPC server's maximum receive message size.
GRPCMaxRecvBytes int
// DispatchByteBudget bounds the total marshalled payload bytes in flight to ingest.
DispatchByteBudget int64
// RawResponseMode selects whether the dispatch envelope carries the
// verbatim upstream response bytes, the adapter's reduction, or both.
// Zero value is RawResponseOff, so a Config built by a test or an
// older caller keeps the historical wire shape.
RawResponseMode RawResponseMode
}
Config holds configuration for the tapes-extproc adapter.
func ConfigFromEnv ¶
func ConfigFromEnv() Config
ConfigFromEnv reads configuration from environment variables.
type ContextObserver ¶
type ContextObserver interface {
OnAcceptedContext(provider string, requestID string, ctx OutcomeContext)
OnDropContext(provider string, reason DropReason, requestID string, ctx OutcomeContext)
}
ContextObserver is an optional extension for observers that need safe per-turn context. The base Observer stays small for existing tests; metrics implements this richer interface to emit low-cardinality terminal outcomes.
type DispatchObserver ¶
type DispatchObserver interface {
OnDispatchLatency(provider string, requestID string, seconds float64)
OnInflight(n int)
}
DispatchObserver is an optional extension for dispatch-stage metrics.
type DispatchedSessionEnvelope ¶
type DispatchedSessionEnvelope struct {
OrgID string `json:"org_id"`
AuthSubject string `json:"auth_subject"`
HarnessID string `json:"harness_id"`
HarnessSessionID string `json:"harness_session_id,omitempty"`
HarnessVersion string `json:"harness_version,omitempty"`
Cwd string `json:"cwd,omitempty"`
Name string `json:"name,omitempty"`
ParentHarnessSessionID string `json:"parent_harness_session_id,omitempty"`
HarnessMetadata map[string]any `json:"harness_metadata,omitempty"`
}
DispatchedSessionEnvelope is the session-tracking block posted to tapes-ingest. The JSON tags are the wire shape tapes-ingest parses against; renaming a field is a breaking change to ingest, not just to this struct.
Naming: this type is the wire-shaped sibling of headers.SessionEnvelope (the parsed view of the inbound request). The two are intentionally separate — headers.SessionEnvelope tracks parser-side state (Present, HarnessMetadataMalformed) that has no place on the dispatched JSON, and DispatchedSessionEnvelope carries the auth-derived fields (OrgID, AuthSubject) the headers package can't see. buildSessionEnvelope is the one site that maps between them.
HarnessMetadata is the decoded JSON object (already base64url- decoded by the processor). nil means no metadata header was attached — distinct from "{}" which the caller may attach explicitly.
type Dispatcher ¶
type Dispatcher struct {
// contains filtered or unexported fields
}
Dispatcher owns the "turn → tapes-ingest" path. Kept separate from the processor state machine so retry/backoff/marshal-error handling is one reviewable diff and the silent "_" on json.Marshal that kept the 4-week bug invisible cannot be reintroduced without also breaking its test.
func NewDispatcher ¶
func NewDispatcher(ingestURL string, maxInflight int, byteBudget int64, client *http.Client) *Dispatcher
NewDispatcher returns a Dispatcher with the given ingest URL, in-flight count cap, and in-flight payload byte budget. Non-positive values fall back to the config defaults.
func (*Dispatcher) Dispatch ¶
func (d *Dispatcher) Dispatch(ctx context.Context, env TurnEnvelope)
Dispatch enqueues a turn for POST to tapes-ingest. Returns immediately; the actual HTTP call runs on a goroutine bounded by the semaphore.
func (*Dispatcher) RecordDrop ¶
func (d *Dispatcher) RecordDrop(provider string, reason DropReason, requestID string)
RecordDrop is used by the processor to record drops that happened before the dispatch stage (client disconnect, unknown provider, …) so all drop reasons pass through one observation point.
func (*Dispatcher) RecordDropContext ¶
func (d *Dispatcher) RecordDropContext(provider string, reason DropReason, requestID string, ctx OutcomeContext)
RecordDropContext is the rich variant used when the processor has already parsed request metadata. It keeps metric labels bounded while logs retain enough context to answer model/endpoint/size/status questions.
func (*Dispatcher) SetObserver ¶
func (d *Dispatcher) SetObserver(o Observer)
SetObserver installs a metrics/spy observer. Thread-unsafe once Dispatch is running; call during setup only.
type DropReason ¶
type DropReason string
DropReason is a closed enum of why a turn failed to land in tapes. Keeping it a named type (not a string literal) forces new call sites to add a constant and — by extension — a metric label row. Dashboards and alerts then stay stable against typos.
The enum has two halves, and which half a reason is in is a decision rather than a grouping — see fixtures/drop-reason/, where both halves are specified and the line between them is argued.
const ( DropMarshalError DropReason = "marshal_error" DropIngestReject DropReason = "ingest_reject" DropIngestTimeout DropReason = "ingest_timeout" DropSemFull DropReason = "sem_full" DropClientDisconnect DropReason = "client_disconnect" // DropUpstreamNoResponse: the stream was torn down after the request // completed and before any response byte arrived. Distinct from // DropEmptyResponse, which is a response phase that completed normally // carrying nothing: that is a property of the exchange, this is a // property of the connection. DropUpstreamNoResponse DropReason = "upstream_no_response" // DropMissingStatus: the response phase ended without Envoy ever // sending :status, which violates the ext_proc message contract. It is // a transport reason despite looking like a policy one — no // implementation that reads a status directly off a response can // reach it. DropMissingStatus DropReason = "missing_status" // DropRequestOverBudget: request accumulation stopped at // requestCaptureBudget — a request that large can never land at ingest, // so the turn is shed before any marshal or POST. Forwarding is untouched. DropRequestOverBudget DropReason = "request_over_budget" )
The transport and runtime half. These are correctly this adapter's own: each one names a way THIS deployment can fail to move bytes, and an implementation without a dispatch queue, a downstream client connection or a remote ingest endpoint cannot produce them. Promoting them to the shared vocabulary would specify one deployment's plumbing as everyone's contract.
They stay declared here for exactly that reason, and the corpus specifies them as non-contract so that "not shared" is recorded rather than assumed.
func AllDropReasons ¶
func AllDropReasons() []DropReason
AllDropReasons enumerates every constant above so metric wiring can preallocate label rows and the metric-enumeration test can assert completeness.
type Metrics ¶
type Metrics struct {
// contains filtered or unexported fields
}
func NewMetrics ¶
func NewMetrics() *Metrics
NewMetrics constructs a fresh registry populated with the full extproc set. AllDropReasons label rows are pre-created so /metrics renders every possible reason even before the first drop of that kind.
func (*Metrics) AsObserver ¶
AsObserver adapts Metrics to the Dispatcher.Observer interface so the extproc dispatcher can emit terminal outcomes without importing promhttp.
func (*Metrics) LargeTurnThreshold ¶
LargeTurnThreshold returns the byte threshold above which a turn is counted in tapes_extproc_turns_large_total. Configurable via SetLargeTurnThreshold; defaults to DefaultLargeTurnThreshold.
func (*Metrics) ObserveAccepted ¶
ObserveAccepted increments captured_total for a provider.
func (*Metrics) ObserveBodyBytes ¶
ObserveBodyBytes records accumulated body size by side ("request" | "response").
func (*Metrics) ObserveBodyBytesByOutcome ¶
ObserveBodyBytesByOutcome records body-size distributions for both accepted and dropped turns. This complements tapes_extproc_body_bytes, which historically measured only successful captured turns.
func (*Metrics) ObserveDispatchLatency ¶
ObserveDispatchLatency records the HTTP POST roundtrip to ingest.
func (*Metrics) ObserveDrop ¶
func (m *Metrics) ObserveDrop(provider string, reason DropReason)
ObserveDrop increments dropped_total for a provider / reason.
func (*Metrics) ObserveRawResponseAttached ¶
ObserveRawResponseAttached counts a turn dispatched with verbatim bytes.
func (*Metrics) ObserveRawResponseFallback ¶
ObserveRawResponseFallback counts a raw-only turn that kept its reduction.
func (*Metrics) ObserveRawResponseSkipped ¶
ObserveRawResponseSkipped counts a turn whose verbatim bytes were withheld.
func (*Metrics) ObserveReducerEmpty ¶
ObserveReducerEmpty increments the counter that tracks reducer outputs that would fail tapes ingest's validator. Labels surface the upstream shape (content_type, HTTP status) so an operator can tell whether the empty cases concentrate on, e.g., text/event-stream@200 (real reducer bug) vs. application/json@5xx (upstream error envelope captured as if it were a turn). The content-type label is normalized to its bare MIME type to keep cardinality bounded.
func (*Metrics) ObserveRequestContentLength ¶ added in v0.35.0
ObserveRequestContentLength records a parsed request Content-Length at the request-headers phase. Callers must only pass successfully parsed values; absent/unparseable headers go through ObserveRequestContentLengthUnknown instead so the histogram's low buckets are never corrupted by zero stand-ins.
func (*Metrics) ObserveRequestContentLengthUnknown ¶ added in v0.35.0
ObserveRequestContentLengthUnknown counts a request whose Content-Length was absent or unparseable at the request-headers phase. Increments only the unknown counter — never the content-length histogram.
func (*Metrics) ObserveResponseDecodeSalvaged ¶
ObserveResponseDecodeSalvaged records a successful decode from a truncated compressed response body. It is separate from response_decoded_total so operators can tell normal gzip@ok traffic from Option-A salvage recoveries.
func (*Metrics) ObserveResponseDecoded ¶
ObserveResponseDecoded records one decode attempt on the upstream response body. encoding is the raw Content-Encoding header value (normalized to lowercase, empty/labelIdentity mapped to labelIdentity). result is "ok" or "error". The metric is purely observational — success doesn't mean the reducer was happy with the decoded bytes, just that decode itself succeeded. Pair with reducer_empty_total when investigating which path is dominant.
func (*Metrics) ObserveSSEChunks ¶
ObserveSSEChunks records how many SSE frames a streamed turn produced.
func (*Metrics) ObserveTerminal ¶
func (m *Metrics) ObserveTerminal(provider, outcome, reason string, ctx OutcomeContext)
ObserveTerminal records the bounded terminal outcome dimensions that let operators correlate drops with endpoint, stream mode, model family, and upstream status class without promoting raw paths/models/request IDs to labels.
func (*Metrics) ObserveTerminalDuration ¶
ObserveTerminalDuration records header-to-terminal latency for accepted and dropped turns. Zero or negative durations are ignored so partially-populated contexts don't create misleading near-zero rows.
func (*Metrics) ObserveTurnDuration ¶
ObserveTurnDuration records the header-to-dispatch wall time.
func (*Metrics) ObserveTurnSize ¶
ObserveTurnSize increments tapes_extproc_turns_large_total when the response body exceeded the threshold. Called unconditionally on every dispatched turn; the threshold check lives here so the call site stays one line.
func (*Metrics) Registry ¶
func (m *Metrics) Registry() *prometheus.Registry
Registry exposes the *prometheus.Registry so callers can mount the scrape handler or assert on metric state.
func (*Metrics) SetBuildInfo ¶ added in v0.35.0
SetBuildInfo publishes the build metadata series. The value is always exactly 1 (Prometheus build_info convention — enables group_left joins).
func (*Metrics) SetInflight ¶
SetInflight updates the gauge of currently-dispatching turns.
func (*Metrics) SetLargeTurnThreshold ¶
SetLargeTurnThreshold overrides the "large turn" byte threshold. Useful for tests and for per-environment tuning once real p99 data is in.
type Observer ¶
type Observer interface {
OnAccepted(provider string, requestID string)
OnDrop(provider string, reason DropReason, requestID string)
}
Observer hooks every terminal outcome. nil-safe.
type OutcomeContext ¶
type OutcomeContext struct {
Method string
Path string
// ThreadID is the harness sub-thread identifier (e.g. a Claude Code
// subagent agent-id), "" for main-thread calls. It is high-cardinality
// and surfaced ONLY in structured logs via logAttrs — never folded into
// a metric label set. Metrics read the bounded fields below directly
// through normalizers (see Metrics.ObserveTerminal), so this field is
// invisible to the metrics path.
ThreadID string
Endpoint string
Model string
ModelFamily string
Stream string
ContentType string
ContentEncoding string
UpstreamStatus int
UpstreamStatusClass string
RequestBytes int
ResponseBytes int
ElapsedSeconds float64
}
OutcomeContext carries safe request/response metadata for logs and metrics. Raw high-cardinality values are used only in structured logs; metrics fold them through bounded label normalizers in Metrics.
type Processor ¶
type Processor struct {
extprocv3.UnimplementedExternalProcessorServer
// contains filtered or unexported fields
}
Processor implements the ext_proc ExternalProcessor service. It is the sidecar that observes traffic through a tenant's Envoy AI Gateway and forwards completed turns to tapes-ingest.
The processor is a per-stream state machine: accumulate request and response bodies with append (never overwrite), gate dispatch on EndOfStream so partial frames don't trigger a premature POST, and issue ModeOverride to FULL_DUPLEX_STREAMED only when stream:true is seen in the request body so non-streaming turns stay on the default BUFFERED path. Body accumulators are plain bytes.Buffer — per-turn memory scales with the upstream response, ceiling'd in practice by the caller's max_tokens. tapes_extproc_body_bytes (histogram) and tapes_extproc_turns_large_total (counter above LargeTurnThreshold) track the distribution so cluster sizing can be adjusted from real data.
func NewProcessor ¶
NewProcessor builds a Processor from Config.
func (*Processor) Dispatcher ¶
func (p *Processor) Dispatcher() *Dispatcher
Dispatcher returns the dispatch path, exposed for tests and metric wiring.
func (*Processor) Metrics ¶
Metrics exposes the Prometheus registry so the cmd wiring can mount /metrics on its existing HTTP mux.
func (*Processor) Process ¶
func (p *Processor) Process(stream extprocv3.ExternalProcessor_ProcessServer) error
Process implements the bidirectional ext_proc RPC. The state machine is linear: RequestHeaders → RequestBody* → ResponseHeaders → ResponseBody* with dispatch firing once on response-body EOS (or on client disconnect for a drop metric).
func (*Processor) SetProviderMap ¶
SetProviderMap replaces the backend-to-provider mapping used by resolveProvider. Tests use this to exercise the unknown-provider path without creating a real ProviderMapFile on disk.
type RawLaneObserver ¶
type RawLaneObserver interface {
// OnRawResponseAttached fires when verbatim bytes go on the wire.
// shape is "dual" or "raw_only".
OnRawResponseAttached(provider, shape string)
// OnRawResponseSkipped fires when the mode wanted bytes but they were
// withheld. The row lands fidelity:reduced.
OnRawResponseSkipped(provider, reason string)
// OnRawResponseFallback fires when a raw-only turn kept its reduction
// because ingest could not have produced one.
OnRawResponseFallback(provider, reason string)
}
RawLaneObserver is an optional extension covering the verbatim-bytes lane. Every outcome is non-fatal to the turn, so none of them belong in DropReason — but each one silently changes the fidelity a row lands with, which makes them exactly the things an operator needs counted rather than inferred.
type RawResponseMode ¶
type RawResponseMode string
RawResponseMode selects what the dispatch envelope carries for the response half of a turn. It exists to make the migration from "extproc reduces" to "tapes reduces" a config change with a proving step in the middle, rather than a flag day.
off — the historical shape: the adapter's reduction only. No verbatim
bytes leave the process. Stored rows land fidelity:reduced.
dual — reduction AND verbatim bytes. Ingest keeps the adapter's reduction
(it consumed the live stream and may have seen framing the stored
bytes no longer show) and stores the bytes alongside it, so rows
land fidelity:raw while the rendered response is unchanged.
raw — verbatim bytes only. Ingest reduces server-side with the shared
pkg/capture reducers. This is the end state: one reducer for every
capture path means two paths cannot reduce differently.
Rollout is deliberately ratcheted: off → dual (prove equivalence on real traffic, since dual changes nothing an operator sees while making the bytes available for comparison) → raw (delete the second reducer). See README.
const ( // RawResponseOff is the default. Changing the default is a separate, // deliberate decision from making the mode available. RawResponseOff RawResponseMode = "off" // RawResponseDual sends both halves. RawResponseDual RawResponseMode = "dual" // RawResponseRaw sends verbatim bytes and no reduction. RawResponseRaw RawResponseMode = "raw" )
func ParseRawResponseMode ¶
func ParseRawResponseMode(s string) (RawResponseMode, error)
ParseRawResponseMode maps a config string onto the enum. An unrecognized value is an error rather than a silent fallback: a typo'd mode that quietly disables the raw lane would look exactly like a working deployment right up until someone asks why nothing is fidelity:raw.
type TurnEnvelope ¶
type TurnEnvelope struct {
Provider string `json:"provider"`
AgentName string `json:"agent_name,omitempty"`
Request json.RawMessage `json:"request"`
Response *llm.ChatResponse `json:"response"`
// RawResponse is the upstream response body exactly as it arrived on
// the wire — still under whatever Content-Encoding the upstream used.
// encoding/json renders it as standard padded base64.
//
// It is NOT the decoded bytes: tapes stores the column byte-faithfully
// and decodes only to reduce, so decompressing here would make the
// stored bytes something the upstream never sent.
RawResponse []byte `json:"raw_response,omitempty"`
// RawResponseEncoding is the Content-Encoding describing RawResponse.
// Empty means identity. Ingest needs it to decode before reducing;
// without it the bytes are unreducible archive.
RawResponseEncoding string `json:"raw_response_encoding,omitempty"`
// RawResponseWithheld says this adapter captured verbatim bytes for the
// turn and chose not to send them — always because including them would
// have pushed the envelope past ingest's body limit, and a turn without
// its bytes beats no turn at all.
//
// Only the producer can know this. An envelope with no raw_response is
// otherwise the same envelope whether the bytes never existed or were
// dropped on the way out, and those are opposite operational facts: the
// first says which adapters are deployed, the second says a limit bit and
// wants tuning. Ingest folds this into raw_response_dropped; the field is
// spelled differently on purpose, because that column is the union of "the
// producer withheld" and "ingest capped" and this is only the first.
//
// It is set at exactly the two places bytes are withheld after being
// captured — the pre-dispatch transport-budget decision, and
// enforceBodyLimit's post-marshal strip — and nowhere else. In particular
// it is NOT set when the mode never asked for bytes: mode=off captured
// nothing to withhold, and marking those turns would report the whole
// fleet as losing bytes it never had.
//
// Invariant, relied on by ingest, which honors the marker only when no
// bytes arrived: withheld implies raw_response absent. Both writers below
// clear the bytes in the same breath as setting this.
RawResponseWithheld bool `json:"raw_response_withheld,omitempty"`
// No omitempty: it has no effect on a non-pointer struct, and meta is
// always sent.
Meta TurnMeta `json:"meta"`
// Session is the optional session-tracking block. Present
// (non-nil) when the inbound request carried any X-Tapes-*
// header; nil otherwise. omitempty keeps the wire shape stable
// for tapes-ingest endpoints that don't know about session
// blocks yet.
Session *DispatchedSessionEnvelope `json:"session,omitempty"`
// contains filtered or unexported fields
}
TurnEnvelope is the JSON body posted to tapes-ingest. Request stays raw provider JSON while Response is the already-reduced canonical LLM response.
type TurnMeta ¶
type TurnMeta struct {
RequestID string `json:"request_id,omitempty"`
ContentType string `json:"content_type,omitempty"`
// ThreadID is the harness's sub-thread identifier for this call
// (e.g. Claude Code's subagent agent-id), "" for main-thread
// calls. Resolved harness-neutrally by headers.ThreadID.
ThreadID string `json:"thread_id,omitempty"`
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
Model string `json:"model,omitempty"`
ModelFamily string `json:"model_family,omitempty"`
Stream string `json:"stream,omitempty"`
ContentEncoding string `json:"content_encoding,omitempty"`
UpstreamStatus int `json:"upstream_status,omitempty"`
UpstreamStatusClass string `json:"upstream_status_class,omitempty"`
RequestBytes int `json:"request_bytes,omitempty"`
ResponseBytes int `json:"response_bytes,omitempty"`
ElapsedSeconds float64 `json:"elapsed_seconds,omitempty"`
}
TurnMeta carries capture-side metadata. Kept in its own struct so it can grow without disturbing the Provider/Request/Response contract ingest already accepts.
Every field serializes onto the wire envelope: tapes-ingest persists the whole meta block verbatim into its immutable raw-turn store, so anything captured here is recoverable downstream without re-capture. Ingest deployments that predate the raw store simply ignore the extra keys.