Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/workspace-engine/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,14 @@ require (
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/teambition/rrule-go v1.8.2
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0
go.opentelemetry.io/contrib/instrumentation/runtime v0.63.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0
go.opentelemetry.io/otel/sdk v1.42.0
go.opentelemetry.io/otel/sdk/log v0.14.0
go.opentelemetry.io/otel/sdk/metric v1.42.0
go.opentelemetry.io/otel/trace v1.43.0
k8s.io/apimachinery v0.34.1
Expand Down Expand Up @@ -296,6 +299,7 @@ require (
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.58.0 // indirect
go.opentelemetry.io/otel/log v0.14.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
Expand Down
10 changes: 10 additions & 0 deletions apps/workspace-engine/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -972,6 +972,8 @@ github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQ
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 h1:bwnLpizECbPr1RrQ27waeY2SPIPeccCx/xLuoYADZ9s=
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0/go.mod h1:3nWlOiiqA9UtUnrcNk82mYasNxD8ehOspL0gOfEo6Y4=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 h1:2pn7OzMewmYRiNtv1doZnLo3gONcnMHlFnmOR8Vgt+8=
Expand All @@ -982,6 +984,8 @@ go.opentelemetry.io/contrib/instrumentation/runtime v0.63.0 h1:PeBoRj6af6xMI7qCu
go.opentelemetry.io/contrib/instrumentation/runtime v0.63.0/go.mod h1:ingqBCtMCe8I4vpz/UVzCW6sxoqgZB37nao91mLQ3Bw=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0 h1:QQqYw3lkrzwVsoEX0w//EhH/TCnpRdEenKBOOEIMjWc=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0/go.mod h1:gSVQcr17jk2ig4jqJ2DX30IdWH251JcNAecvrqTxH1s=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 h1:vl9obrcoWVKp/lwl8tRE33853I8Xru9HFbw/skNeLs8=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0/go.mod h1:GAXRxmLJcVM3u22IjTg74zWBrRCKq8BnOqUVLodpcpw=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0 h1:Oe2z/BCg5q7k4iXC3cqJxKYg0ieRiOqF0cecFYdPTwk=
Expand All @@ -994,10 +998,16 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY=
go.opentelemetry.io/otel/exporters/prometheus v0.58.0 h1:CJAxWKFIqdBennqxJyOgnt5LqkeFRT+Mz3Yjz3hL+h8=
go.opentelemetry.io/otel/exporters/prometheus v0.58.0/go.mod h1:7qo/4CLI+zYSNbv0GMNquzuss2FVZo3OYrGh96n4HNc=
go.opentelemetry.io/otel/log v0.14.0 h1:2rzJ+pOAZ8qmZ3DDHg73NEKzSZkhkGIua9gXtxNGgrM=
go.opentelemetry.io/otel/log v0.14.0/go.mod h1:5jRG92fEAgx0SU/vFPxmJvhIuDU9E1SUnEQrMlJpOno=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
go.opentelemetry.io/otel/sdk/log v0.14.0 h1:JU/U3O7N6fsAXj0+CXz21Czg532dW2V4gG1HE/e8Zrg=
go.opentelemetry.io/otel/sdk/log v0.14.0/go.mod h1:imQvII+0ZylXfKU7/wtOND8Hn4OpT3YUoIgqJVksUkM=
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM=
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
Expand Down
81 changes: 81 additions & 0 deletions apps/workspace-engine/log_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package main

import (
"context"
"errors"
"log/slog"
)

type teeHandler struct {
handlers []slog.Handler
}

func newTeeHandler(handlers ...slog.Handler) *teeHandler {
return &teeHandler{handlers: handlers}
}

func (t *teeHandler) Enabled(ctx context.Context, level slog.Level) bool {
for _, h := range t.handlers {
if h.Enabled(ctx, level) {
return true
}
}
return false
}

func (t *teeHandler) Handle(ctx context.Context, r slog.Record) error {
var errs []error
for _, h := range t.handlers {
if !h.Enabled(ctx, r.Level) {
continue
}
if err := h.Handle(ctx, r.Clone()); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}

func (t *teeHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
next := make([]slog.Handler, len(t.handlers))
for i, h := range t.handlers {
next[i] = h.WithAttrs(attrs)
}
return &teeHandler{handlers: next}
}

func (t *teeHandler) WithGroup(name string) slog.Handler {
next := make([]slog.Handler, len(t.handlers))
for i, h := range t.handlers {
next[i] = h.WithGroup(name)
}
return &teeHandler{handlers: next}
}

type levelHandler struct {
handler slog.Handler
level slog.Level
}

func newLevelHandler(level slog.Level, h slog.Handler) *levelHandler {
return &levelHandler{handler: h, level: level}
}

func (l *levelHandler) Enabled(ctx context.Context, lvl slog.Level) bool {
if lvl < l.level {
return false
}
return l.handler.Enabled(ctx, lvl)
}

func (l *levelHandler) Handle(ctx context.Context, r slog.Record) error {
return l.handler.Handle(ctx, r)
}

func (l *levelHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &levelHandler{handler: l.handler.WithAttrs(attrs), level: l.level}
}

func (l *levelHandler) WithGroup(name string) slog.Handler {
return &levelHandler{handler: l.handler.WithGroup(name), level: l.level}
}
124 changes: 124 additions & 0 deletions apps/workspace-engine/log_handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package main

import (
"bytes"
"context"
"errors"
"log/slog"
"strings"
"testing"
"time"
)

func TestTeeHandlerForwardsToBoth(t *testing.T) {
var bufA, bufB bytes.Buffer
h := newTeeHandler(
slog.NewTextHandler(&bufA, &slog.HandlerOptions{Level: slog.LevelDebug}),
slog.NewTextHandler(&bufB, &slog.HandlerOptions{Level: slog.LevelDebug}),
)
logger := slog.New(h)
logger.Info("hello", "key", "val")

if !strings.Contains(bufA.String(), "hello") || !strings.Contains(bufA.String(), "key=val") {
t.Fatalf("handler A missing record: %q", bufA.String())
}
if !strings.Contains(bufB.String(), "hello") || !strings.Contains(bufB.String(), "key=val") {
t.Fatalf("handler B missing record: %q", bufB.String())
}
}

func TestTeeHandlerEnabledIsUnion(t *testing.T) {
infoOnly := slog.NewTextHandler(&bytes.Buffer{}, &slog.HandlerOptions{Level: slog.LevelInfo})
debugOnly := slog.NewTextHandler(&bytes.Buffer{}, &slog.HandlerOptions{Level: slog.LevelDebug})
h := newTeeHandler(infoOnly, debugOnly)

if !h.Enabled(context.Background(), slog.LevelDebug) {
t.Fatal("expected DEBUG enabled because debugOnly accepts it")
}
if !h.Enabled(context.Background(), slog.LevelInfo) {
t.Fatal("expected INFO enabled")
}
}

func TestTeeHandlerWithAttrsPropagates(t *testing.T) {
var bufA, bufB bytes.Buffer
h := newTeeHandler(
slog.NewTextHandler(&bufA, nil),
slog.NewTextHandler(&bufB, nil),
)
logger := slog.New(h).With("svc", "we")
logger.Info("up")

for name, got := range map[string]string{"A": bufA.String(), "B": bufB.String()} {
if !strings.Contains(got, "svc=we") {
t.Fatalf("handler %s missing With attrs: %q", name, got)
}
}
}

func TestTeeHandlerWithGroupPropagates(t *testing.T) {
var bufA, bufB bytes.Buffer
h := newTeeHandler(
slog.NewTextHandler(&bufA, nil),
slog.NewTextHandler(&bufB, nil),
)
logger := slog.New(h).WithGroup("net").With("port", 8080)
logger.Info("up")

for name, got := range map[string]string{"A": bufA.String(), "B": bufB.String()} {
if !strings.Contains(got, "net.port=8080") {
t.Fatalf("handler %s missing grouped attr: %q", name, got)
}
}
}

type errHandler struct {
enabled bool
err error
}

func (e *errHandler) Enabled(context.Context, slog.Level) bool { return e.enabled }
func (e *errHandler) Handle(context.Context, slog.Record) error { return e.err }
func (e *errHandler) WithAttrs([]slog.Attr) slog.Handler { return e }
func (e *errHandler) WithGroup(string) slog.Handler { return e }

func TestTeeHandlerJoinsErrors(t *testing.T) {
errA := errors.New("a failed")
errB := errors.New("b failed")
h := newTeeHandler(
&errHandler{enabled: true, err: errA},
&errHandler{enabled: true, err: errB},
)
err := h.Handle(context.Background(), slog.NewRecord(time.Time{}, slog.LevelInfo, "msg", 0))
if !errors.Is(err, errA) || !errors.Is(err, errB) {
t.Fatalf("expected joined errors to wrap both errA and errB, got: %v", err)
}
}

func TestLevelHandlerFiltersBelowThreshold(t *testing.T) {
var buf bytes.Buffer
inner := slog.NewTextHandler(&buf, nil)
h := newLevelHandler(slog.LevelWarn, inner)

if h.Enabled(context.Background(), slog.LevelInfo) {
t.Fatal("INFO should be disabled when threshold is WARN")
}
if !h.Enabled(context.Background(), slog.LevelWarn) {
t.Fatal("WARN should be enabled at threshold WARN")
}
if !h.Enabled(context.Background(), slog.LevelError) {
t.Fatal("ERROR should be enabled above threshold WARN")
}

logger := slog.New(h)
logger.Info("hidden")
logger.Warn("visible")

got := buf.String()
if strings.Contains(got, "hidden") {
t.Fatalf("INFO record leaked through level filter: %q", got)
}
if !strings.Contains(got, "visible") {
t.Fatalf("WARN record was dropped: %q", got)
}
}
19 changes: 14 additions & 5 deletions apps/workspace-engine/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package main

import (
"context"
"fmt"
"log/slog"
"os"
"strings"
"time"

"github.com/charmbracelet/log"
"github.com/google/uuid"
"workspace-engine/pkg/config"
"workspace-engine/pkg/db"
Expand All @@ -31,6 +33,13 @@ var (
)

func main() {
cleanupLogger, err := initLogger()
if err != nil {
fmt.Fprintf(os.Stderr, "failed to init logger: %v\n", err)
os.Exit(1)
}
defer cleanupLogger()

cleanupTracer, _ := initTracer()
defer cleanupTracer()

Expand Down Expand Up @@ -73,15 +82,15 @@ func main() {
continue
}

log.Info("Adding service", "name", s.Name())
slog.Info("Adding service", "name", s.Name())
runner.Add(s)
}

log.Info("Enabled services", "services", enabled)
slog.Info("Enabled services", "services", enabled)

if err := runner.Run(ctx); err != nil {
log.Error("Runner failed", "error", err)
slog.Error("Runner failed", "error", err)
}

log.Info("Workspace engine shut down")
slog.Info("Workspace engine shut down")
}
Loading
Loading