Skip to content
skyl

HookEvent

Describes one completed attempt against a provider.

HookEvent is what a Hook receives. It carries enough to build metrics, logs and cost accounting — and one field that carries the user's prompt.

Reference#

type HookEvent struct
FieldTypeDescription
ProviderstringThe adapter that was called.
ModelstringThe model that was *asked for*. See ResponseModel for the one that answered.
OperationstringOne of complete, stream, stream_end, or models.
AttemptintThe zero-based retry attempt this event reports.
Durationtime.DurationHow long the attempt took. For stream that is the handshake alone; for stream_end it is the whole stream, handshake included.
ErrerrorThe attempt's error, or nil. A non-nil Err on a non-final attempt was retried.
UsageUsageToken consumption. Populated for successful complete calls, and for stream_end when the stream ran to completion.
ResponseIDstringThe provider's identifier for the response, when it gave one.
ResponseModelstringThe model that actually served the request.
StopReasonStopReasonWhy generation ended, for complete and a completed stream_end.
CompletedboolWhether a stream ran to its terminal event. Meaningful only for stream_end. False means the caller closed the stream early — those tokens were still generated and still billed, which is why the event fires anyway.
Request*RequestThe request that produced this event. **It carries the prompt.** Anything a hook does with it is a decision about user data: logging it verbatim ships conversation content wherever the logs go. Treat it as read-only; skyl reuses it across retries.

The four operations#

ConstantValueWhen it firesUsage?
OpCompletecompleteOnce per attempt of a non-streaming requestYes, on success
OpStreamstreamThe streaming handshake, before any token existsNo
OpStreamEndstream_endOnce when a stream finishes or is closedYes, if completed
OpModelsmodelsOnce per attempt of a model listingNo

Caveats

  • Model is what you asked for; ResponseModel is what answered. Group metrics by the second, or two snapshots behind one alias merge into one line.
  • stream_end fires even for an abandoned stream, with Completed: false — those tokens were generated and billed, so reporting nothing would make the spend invisible.
  • Request carries the prompt. It is supplied so a hook can report sampling parameters without this struct growing a field per parameter — but anything you do with it is a decision about user data.
  • Attempt is zero-based, so Attempt > 0 means "this was a retry".
  • Duration for OpStream is the handshake alone; for OpStreamEnd it is the whole stream, handshake included.

Usage#

Cost accounting across both call styles

goCompiles
skyl.WithHook(func(_ context.Context, ev skyl.HookEvent) {
	// Only these two carry usage; `stream` fires before generation.
	if ev.Operation != skyl.OpComplete && ev.Operation != skyl.OpStreamEnd {
		return
	}
	key := ev.ResponseModel
	if key == "" {
		key = ev.Model // the provider did not report it
	}
	costs.Add(key, ev.Usage)
})
skyl.WithHook(func(_ context.Context, ev skyl.HookEvent) {
	// Only these two carry usage; `stream` fires before generation.
	if ev.Operation != skyl.OpComplete && ev.Operation != skyl.OpStreamEnd {
		return
	}
	key := ev.ResponseModel
	if key == "" {
		key = ev.Model // the provider did not report it
	}
	costs.Add(key, ev.Usage)
})

Detecting alias movement

goCompiles
if ev.ResponseModel != "" && ev.ResponseModel != ev.Model {
	log.Printf("substitution: asked %q, served %q (response %s)",
		ev.Model, ev.ResponseModel, ev.ResponseID)
}
if ev.ResponseModel != "" && ev.ResponseModel != ev.Model {
	log.Printf("substitution: asked %q, served %q (response %s)",
		ev.Model, ev.ResponseModel, ev.ResponseID)
}

Log at info, not warning — alias resolution is constant and would drown your logs.

Measuring the retry rate

goCompiles
skyl.WithHook(func(_ context.Context, ev skyl.HookEvent) {
	if ev.Operation != skyl.OpComplete {
		return
	}
	attempts.Add(1)
	if ev.Attempt > 0 {
		retries.Add(1)
	}
})
skyl.WithHook(func(_ context.Context, ev skyl.HookEvent) {
	if ev.Operation != skyl.OpComplete {
		return
	}
	attempts.Add(1)
	if ev.Attempt > 0 {
		retries.Add(1)
	}
})

A retry rate creeping from 0.5% to 5% is the earliest warning that a provider is degrading — usually before it shows up as user-visible latency, because backoff is absorbing it.

Reporting sampling parameters without leaking the prompt

goCompiles
skyl.WithHook(func(_ context.Context, ev skyl.HookEvent) {
	span.SetAttributes(
		attribute.Int("gen_ai.request.max_tokens", ev.Request.MaxTokens),
		attribute.Int("gen_ai.request.messages", len(ev.Request.Messages)),
	)
	// Deliberately NOT ev.Request.Messages — a span is a durable record
	// shipped to a third-party backend.
})
skyl.WithHook(func(_ context.Context, ev skyl.HookEvent) {
	span.SetAttributes(
		attribute.Int("gen_ai.request.max_tokens", ev.Request.MaxTokens),
		attribute.Int("gen_ai.request.messages", len(ev.Request.Messages)),
	)
	// Deliberately NOT ev.Request.Messages — a span is a durable record
	// shipped to a third-party backend.
})

Troubleshooting#

stream_end reports zero usage

If Completed is false, the caller abandoned the stream and little arrived — expected. If it is true, the provider did not report usage: on OpenAI-family hosts that means stream_options.include_usage was not honoured.

ResponseModel is empty

Best-effort. A provider that does not report the serving model leaves it empty. Fall back to Model.

I see more events than requests

Correct — one per attempt. A request retried three times produces four complete events. A stream produces one stream plus one stream_end.

Edit this page on GitHub