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#
| Field | Type | Description |
|---|---|---|
| Provider | string | The adapter that was called. |
| Model | string | The model that was *asked for*. See ResponseModel for the one that answered. |
| Operation | string | One of complete, stream, stream_end, or models. |
| Attempt | int | The zero-based retry attempt this event reports. |
| Duration | time.Duration | How long the attempt took. For stream that is the handshake alone; for stream_end it is the whole stream, handshake included. |
| Err | error | The attempt's error, or nil. A non-nil Err on a non-final attempt was retried. |
| Usage | Usage | Token consumption. Populated for successful complete calls, and for stream_end when the stream ran to completion. |
| ResponseID | string | The provider's identifier for the response, when it gave one. |
| ResponseModel | string | The model that actually served the request. |
| StopReason | StopReason | Why generation ended, for complete and a completed stream_end. |
| Completed | bool | Whether 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 | *Request | The 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#
| Constant | Value | When it fires | Usage? |
|---|---|---|---|
OpComplete | complete | Once per attempt of a non-streaming request | Yes, on success |
OpStream | stream | The streaming handshake, before any token exists | No |
OpStreamEnd | stream_end | Once when a stream finishes or is closed | Yes, if completed |
OpModels | models | Once per attempt of a model listing | No |
Caveats
Modelis what you asked for;ResponseModelis what answered. Group metrics by the second, or two snapshots behind one alias merge into one line.stream_endfires even for an abandoned stream, withCompleted: false— those tokens were generated and billed, so reporting nothing would make the spend invisible.Requestcarries 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.Attemptis zero-based, soAttempt > 0means "this was a retry".DurationforOpStreamis the handshake alone; forOpStreamEndit is the whole stream, handshake included.
Usage#
Cost accounting across both call styles
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
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
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
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.