-
-
Notifications
You must be signed in to change notification settings - Fork 110
refactor: share SSE parsing across audit and usage observers #158
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0121da0
49ef852
1507b66
7cf0f62
7c1f717
6c852c6
f33fd45
2ecfd02
be90ea6
c33b705
7876d97
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| package auditlog | ||
|
|
||
| import ( | ||
| "strings" | ||
| "time" | ||
| ) | ||
|
|
||
| // StreamLogObserver reconstructs stream metadata and optional response bodies | ||
| // from parsed SSE JSON payloads. | ||
| type StreamLogObserver struct { | ||
| logger LoggerInterface | ||
| entry *LogEntry | ||
| builder *streamResponseBuilder | ||
| logBodies bool | ||
| closed bool | ||
| startTime time.Time | ||
| } | ||
|
|
||
| func NewStreamLogObserver(logger LoggerInterface, entry *LogEntry, path string) *StreamLogObserver { | ||
| if logger == nil || entry == nil { | ||
| return nil | ||
| } | ||
|
|
||
| logBodies := logger.Config().LogBodies | ||
| var builder *streamResponseBuilder | ||
| if logBodies { | ||
| builder = &streamResponseBuilder{ | ||
| IsResponsesAPI: strings.HasPrefix(path, "/v1/responses"), | ||
| } | ||
| } | ||
|
|
||
| return &StreamLogObserver{ | ||
| logger: logger, | ||
| entry: entry, | ||
| builder: builder, | ||
| logBodies: logBodies, | ||
| startTime: entry.Timestamp, | ||
| } | ||
| } | ||
|
|
||
| func (o *StreamLogObserver) OnJSONEvent(event map[string]interface{}) { | ||
| if !o.logBodies || o.builder == nil { | ||
| return | ||
| } | ||
| if o.builder.IsResponsesAPI { | ||
| o.parseResponsesAPIEvent(event) | ||
| return | ||
| } | ||
| o.parseChatCompletionEvent(event) | ||
| } | ||
|
|
||
| func (o *StreamLogObserver) OnStreamClose() { | ||
| if o.closed { | ||
| return | ||
| } | ||
| o.closed = true | ||
|
|
||
| if o.entry != nil && !o.startTime.IsZero() { | ||
| o.entry.DurationNs = time.Since(o.startTime).Nanoseconds() | ||
| } | ||
|
|
||
| if o.logBodies && o.builder != nil && o.entry != nil && o.entry.Data != nil { | ||
| if o.builder.IsResponsesAPI { | ||
| o.entry.Data.ResponseBody = o.builder.buildResponsesAPIResponse() | ||
| } else { | ||
| o.entry.Data.ResponseBody = o.builder.buildChatCompletionResponse() | ||
| } | ||
| o.entry.Data.ResponseBodyTooBigToHandle = o.builder.truncated | ||
| } | ||
|
|
||
| if o.logger != nil && o.entry != nil { | ||
| o.logger.Write(o.entry) | ||
| } | ||
| } | ||
|
|
||
| func (o *StreamLogObserver) parseChatCompletionEvent(event map[string]interface{}) { | ||
| if o.builder == nil { | ||
| return | ||
| } | ||
|
|
||
| if o.builder.ID == "" { | ||
| if id, ok := event["id"].(string); ok { | ||
| o.builder.ID = id | ||
| } | ||
| if model, ok := event["model"].(string); ok { | ||
| o.builder.Model = model | ||
| } | ||
| if created, ok := event["created"].(float64); ok { | ||
| o.builder.Created = int64(created) | ||
| } | ||
| } | ||
|
Comment on lines
+81
to
+91
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Decouple At Line 81, 💡 Proposed fix- if o.builder.ID == "" {
- if id, ok := event["id"].(string); ok {
- o.builder.ID = id
- }
- if model, ok := event["model"].(string); ok {
- o.builder.Model = model
- }
- if created, ok := event["created"].(float64); ok {
- o.builder.Created = int64(created)
- }
- }
+ if o.builder.ID == "" {
+ if id, ok := event["id"].(string); ok {
+ o.builder.ID = id
+ }
+ }
+ if o.builder.Model == "" {
+ if model, ok := event["model"].(string); ok {
+ o.builder.Model = model
+ }
+ }
+ if o.builder.Created == 0 {
+ if created, ok := event["created"].(float64); ok {
+ o.builder.Created = int64(created)
+ }
+ }🤖 Prompt for AI Agents |
||
|
|
||
| if choices, ok := event["choices"].([]interface{}); ok && len(choices) > 0 { | ||
| if choice, ok := choices[0].(map[string]interface{}); ok { | ||
| if fr, ok := choice["finish_reason"].(string); ok && fr != "" { | ||
| o.builder.FinishReason = fr | ||
| } | ||
|
|
||
| if delta, ok := choice["delta"].(map[string]interface{}); ok { | ||
| if role, ok := delta["role"].(string); ok { | ||
| o.builder.Role = role | ||
| } | ||
| if content, ok := delta["content"].(string); ok && content != "" { | ||
| o.appendContent(content) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func (o *StreamLogObserver) parseResponsesAPIEvent(event map[string]interface{}) { | ||
| if o.builder == nil { | ||
| return | ||
| } | ||
|
|
||
| eventType, _ := event["type"].(string) | ||
| switch eventType { | ||
| case "response.created", "response.completed", "response.done": | ||
| if resp, ok := event["response"].(map[string]interface{}); ok { | ||
| if id, ok := resp["id"].(string); ok { | ||
| o.builder.ResponseID = id | ||
| } | ||
| if status, ok := resp["status"].(string); ok { | ||
| o.builder.Status = status | ||
| } | ||
| if model, ok := resp["model"].(string); ok { | ||
| o.builder.Model = model | ||
| } | ||
| if createdAt, ok := resp["created_at"].(float64); ok { | ||
| o.builder.CreatedAt = int64(createdAt) | ||
| } | ||
| } | ||
| case "response.output_text.delta": | ||
| if delta, ok := event["delta"].(string); ok && delta != "" { | ||
| o.appendContent(delta) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func (o *StreamLogObserver) appendContent(content string) { | ||
| if o.builder == nil || o.builder.truncated || o.builder.contentLen >= MaxContentCapture { | ||
| return | ||
| } | ||
|
Comment on lines
+141
to
+143
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Set truncation flag when content is dropped at capacity. At Line 141, extra chunks are dropped once capacity is reached, but 💡 Proposed fix func (o *StreamLogObserver) appendContent(content string) {
- if o.builder == nil || o.builder.truncated || o.builder.contentLen >= MaxContentCapture {
+ if o.builder == nil || o.builder.truncated || content == "" {
+ return
+ }
+ if o.builder.contentLen >= MaxContentCapture {
+ o.builder.truncated = true
return
}
remaining := MaxContentCapture - o.builder.contentLen
if len(content) > remaining {
content = content[:remaining]
o.builder.truncated = true
}
o.builder.Content.WriteString(content)
o.builder.contentLen += len(content)
}Also applies to: 145-152 🤖 Prompt for AI Agents |
||
|
|
||
| remaining := MaxContentCapture - o.builder.contentLen | ||
| if len(content) > remaining { | ||
| content = content[:remaining] | ||
| o.builder.truncated = true | ||
| } | ||
| o.builder.Content.WriteString(content) | ||
| o.builder.contentLen += len(content) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Consider precision loss for large Unix timestamps.
JSON numbers unmarshal as
float64, which can represent integers exactly only up to 2^53. While current Unix timestamps (~1.7×10^9) are safe, the castint64(created)would lose precision for nanosecond timestamps or far-future dates. This is likely fine for thecreatedfield (seconds since epoch), but worth noting if this pattern is reused elsewhere.🤖 Prompt for AI Agents