feat(garmin): all-JSON wrapper protocol and log stream
Error responses from wrapper.py now carry error_type and traceback in
the protocol itself, logged as attrs on the Go side's per-call record.
wrapper.py's free-text stderr debug prints become JSON log lines
({level,msg,...attrs}); client.go parses that stream and re-emits it
through the application logger (level-mapped, source=garmin-wrapper),
wrapping any non-JSON line instead of passing it through raw. The
wrapper also survives malformed request lines and unserializable
results with JSON responses instead of crashing with a raw traceback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -77,11 +77,16 @@ type wireRequest struct {
|
||||
}
|
||||
|
||||
// wireResponse is one line read from the wrapper subprocess's stdout.
|
||||
// ErrorType/Traceback carry the Python-side failure detail in the protocol
|
||||
// itself (see wrapper.py's dispatch), so the Go side logs one complete
|
||||
// structured record per failed call instead of correlating stderr noise.
|
||||
type wireResponse struct {
|
||||
ID int `json:"id"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
NotFound bool `json:"not_found,omitempty"`
|
||||
ID int `json:"id"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorType string `json:"error_type,omitempty"`
|
||||
Traceback string `json:"traceback,omitempty"`
|
||||
NotFound bool `json:"not_found,omitempty"`
|
||||
}
|
||||
|
||||
// ErrNotFound wraps any error a Client method returns when the wrapper
|
||||
@@ -186,13 +191,16 @@ func (c *subprocessClient) ensureStarted(ctx context.Context) error {
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("spawn garmin wrapper subprocess: %w", err)
|
||||
}
|
||||
// wrapper.py logs auth/rate-limit diagnostics to stderr. Per os/exec's
|
||||
// StderrPipe docs, it's incorrect to call Wait before all reads from the
|
||||
// pipe have completed, so close() waits on stderrDone before Wait-ing.
|
||||
// wrapper.py logs auth/rate-limit diagnostics to stderr as JSON lines
|
||||
// (see its _log helper); forwardWrapperStderr re-emits them through the
|
||||
// application logger so the backend's output stays one JSON stream. Per
|
||||
// os/exec's StderrPipe docs, it's incorrect to call Wait before all
|
||||
// reads from the pipe have completed, so close() waits on stderrDone
|
||||
// before Wait-ing.
|
||||
c.stderrDone = make(chan struct{})
|
||||
stderrDone := c.stderrDone
|
||||
go func() {
|
||||
io.Copy(os.Stderr, stderr)
|
||||
forwardWrapperStderr(stderr)
|
||||
close(stderrDone)
|
||||
}()
|
||||
|
||||
@@ -217,6 +225,7 @@ func (c *subprocessClient) ensureStarted(ctx context.Context) error {
|
||||
// these wire params.
|
||||
func (c *subprocessClient) roundTrip(ctx context.Context, cmdName string, params any) (result json.RawMessage, err error) {
|
||||
start := time.Now()
|
||||
var errorType, traceback string // Python-side failure detail, set from the error response
|
||||
defer func() {
|
||||
attrs := []slog.Attr{
|
||||
slog.String("cmd", cmdName),
|
||||
@@ -230,6 +239,12 @@ func (c *subprocessClient) roundTrip(ctx context.Context, cmdName string, params
|
||||
if err != nil {
|
||||
level = slog.LevelError
|
||||
attrs = append(attrs, slog.String("error", err.Error()))
|
||||
if errorType != "" {
|
||||
attrs = append(attrs, slog.String("error_type", errorType))
|
||||
}
|
||||
if traceback != "" {
|
||||
attrs = append(attrs, slog.String("traceback", traceback))
|
||||
}
|
||||
}
|
||||
applog.FromContext(ctx).LogAttrs(context.Background(), level, "Garmin wrapper call", attrs...)
|
||||
}()
|
||||
@@ -261,6 +276,7 @@ func (c *subprocessClient) roundTrip(ctx context.Context, cmdName string, params
|
||||
return nil, err
|
||||
}
|
||||
if resp.Error != "" {
|
||||
errorType, traceback = resp.ErrorType, resp.Traceback
|
||||
if resp.NotFound {
|
||||
err = fmt.Errorf("%s: %s: %w", cmdName, resp.Error, ErrNotFound)
|
||||
} else {
|
||||
@@ -352,6 +368,52 @@ func (c *subprocessClient) close() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// forwardWrapperStderr re-emits the wrapper subprocess's stderr through
|
||||
// the application logger. wrapper.py writes JSON lines ({"level","msg",
|
||||
// ...attrs} -- see its _log helper), which are decoded and re-logged at
|
||||
// the corresponding level with their attrs preserved. Anything that isn't
|
||||
// such a line (a Python startup crash before _log exists, a chatty
|
||||
// third-party library printing directly) is wrapped as a warn-level
|
||||
// "garmin wrapper stderr" record rather than passed through raw, so the
|
||||
// backend's combined output is JSON no matter what the subprocess does.
|
||||
func forwardWrapperStderr(r io.Reader) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // tracebacks can exceed the default token size
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
var entry map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &entry); err != nil || entry["msg"] == nil {
|
||||
slog.Warn("garmin wrapper stderr", "line", line)
|
||||
continue
|
||||
}
|
||||
msg, _ := entry["msg"].(string)
|
||||
levelName, _ := entry["level"].(string)
|
||||
delete(entry, "msg")
|
||||
delete(entry, "level")
|
||||
attrs := make([]slog.Attr, 0, len(entry)+1)
|
||||
attrs = append(attrs, slog.String("source", "garmin-wrapper"))
|
||||
for k, v := range entry {
|
||||
attrs = append(attrs, slog.Any(k, v))
|
||||
}
|
||||
slog.LogAttrs(context.Background(), wrapperLogLevel(levelName), msg, attrs...)
|
||||
}
|
||||
}
|
||||
|
||||
// wrapperLogLevel maps wrapper.py's level strings onto slog levels,
|
||||
// defaulting to info for anything unrecognized.
|
||||
func wrapperLogLevel(level string) slog.Level {
|
||||
switch level {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
|
||||
Reference in New Issue
Block a user