package workspace import ( "crypto/rand" "encoding/hex" "sync/atomic" "go.uber.org/zap" iop "iop/proto/gen/iop" ) const workspaceObservationLogKey = "node_workspace_observation" type workspaceObservationEvent string const ( workspaceObservationTool workspaceObservationEvent = "tool" workspaceObservationCleanup workspaceObservationEvent = "cleanup" ) // workspaceObservation is deliberately raw-free. It carries no request id, // workspace ref, path, command id, environment, content, stdout, stderr, or // error text. Correlation is generated at Open and is not derived from any // caller-controlled identity. type workspaceObservation struct { event workspaceObservationEvent correlation string operation string outcome string errorCode string durationMS int64 truncated bool processCount int32 artifactCount int32 } type workspaceObserver interface { Emit(workspaceObservation) error } type workspaceNoopObserver struct{} func (workspaceNoopObserver) Emit(workspaceObservation) error { return nil } type workspaceSafeObserver struct { inner workspaceObserver failures atomic.Int64 } func (o *workspaceSafeObserver) Emit(observation workspaceObservation) error { if o == nil || o.inner == nil { return nil } defer func() { if recover() != nil { o.failures.Add(1) } }() if err := o.inner.Emit(observation); err != nil { o.failures.Add(1) } return nil } func (o *workspaceSafeObserver) failureCount() int64 { if o == nil { return 0 } return o.failures.Load() } type zapWorkspaceObserver struct { logger *zap.Logger } func newZapWorkspaceObserver(logger *zap.Logger) workspaceObserver { if logger == nil { return workspaceNoopObserver{} } return &zapWorkspaceObserver{logger: logger} } func (o *zapWorkspaceObserver) Emit(observation workspaceObservation) error { if o == nil || o.logger == nil { return nil } o.logger.Info(workspaceObservationLogKey, zap.String("correlation", observation.correlation), zap.String("event", string(observation.event)), zap.String("operation", observation.operation), zap.String("outcome", observation.outcome), zap.String("error_code", observation.errorCode), zap.Int64("duration_ms", observation.durationMS), zap.Bool("truncated", observation.truncated), zap.Int32("process_count", observation.processCount), zap.Int32("artifact_count", observation.artifactCount), ) return nil } var workspaceCorrelationFallback atomic.Uint64 func newWorkspaceCorrelation() string { var value [12]byte if _, err := rand.Read(value[:]); err == nil { return "ws-" + hex.EncodeToString(value[:]) } return "ws-fallback-" + formatWorkspaceFallback(workspaceCorrelationFallback.Add(1)) } func formatWorkspaceFallback(value uint64) string { const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz" if value == 0 { return "0" } var encoded [13]byte index := len(encoded) for value > 0 { index-- encoded[index] = alphabet[value%36] value /= 36 } return string(encoded[index:]) } func workspaceOperationName(operation iop.WorkspaceOperation) string { switch operation { case iop.WorkspaceOperation_WORKSPACE_OPERATION_READ: return "read" case iop.WorkspaceOperation_WORKSPACE_OPERATION_LIST: return "list" case iop.WorkspaceOperation_WORKSPACE_OPERATION_WRITE: return "write" case iop.WorkspaceOperation_WORKSPACE_OPERATION_DELETE: return "delete" case iop.WorkspaceOperation_WORKSPACE_OPERATION_COMMAND: return "command" default: return "unknown" } } func workspaceOutcome(status iop.WorkspaceStatus) string { switch status { case iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS: return "success" case iop.WorkspaceStatus_WORKSPACE_STATUS_TIMEOUT: return "timeout" case iop.WorkspaceStatus_WORKSPACE_STATUS_CANCELLED: return "cancelled" case iop.WorkspaceStatus_WORKSPACE_STATUS_UNSUPPORTED: return "unsupported" case iop.WorkspaceStatus_WORKSPACE_STATUS_ERROR: return "error" default: return "unknown" } } func workspaceErrorCode(code iop.WorkspaceErrorCode) string { switch code { case iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_UNSPECIFIED: return "none" case iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_NOT_READY: return "not_ready" case iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_UNSUPPORTED: return "unsupported" case iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_INVALID_REQUEST: return "invalid_request" case iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_NOT_FOUND: return "not_found" case iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_TIMEOUT: return "timeout" case iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_CANCELLED: return "cancelled" case iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_INTERNAL: return "internal" default: return "unknown" } } func (r *Runtime) observeTool(correlation string, operation iop.WorkspaceOperation, result Result) { if r == nil || r.observer == nil { return } r.observer.Emit(workspaceObservation{ event: workspaceObservationTool, correlation: correlation, operation: workspaceOperationName(operation), outcome: workspaceOutcome(result.Status), errorCode: workspaceErrorCode(result.Code), durationMS: result.DurationMS, truncated: result.Truncated, }) } func (r *Runtime) observeCleanup(req *Request, result CleanupResult, durationMS int64) { if r == nil || r.observer == nil { return } correlation := "" if req != nil { correlation = req.correlation } r.observer.Emit(workspaceObservation{ event: workspaceObservationCleanup, correlation: correlation, operation: "cleanup", outcome: workspaceOutcome(result.Status), errorCode: workspaceErrorCode(result.Code), durationMS: durationMS, processCount: result.CleanedProcesses, artifactCount: result.CleanedArtifacts, }) }