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
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,42 @@ PYTTECHAT_LLM_PROXY_TOKEN=token-value \

Set `--reasoning-effort` when you want to request model reasoning options. Assistant answer text streams to stdout. Reasoning events, when returned, stream separately to stderr. Proxy requests default to a 5-minute timeout; override it with `--proxy-timeout` or `PYTTECHAT_LLM_PROXY_TIMEOUT`.

## OpenTelemetry

Telemetry is disabled by default. If no OTLP endpoint is configured, `pyttechat` and `fake-responses` run without an OpenTelemetry Collector and use no external telemetry network calls.

To enable traces and metrics locally, run an OpenTelemetry Collector with the provided development config:

```sh
otelcol --config docs/otel-collector.yaml
```

Then start the app with an OTLP/gRPC endpoint:

```sh
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \
OTEL_SERVICE_NAME=pyttechat \
./bin/pyttechat serve --addr :3000
```

Supported telemetry environment variables:

- `OTEL_SERVICE_NAME`: service name, default `pyttechat`.
- `OTEL_EXPORTER_OTLP_ENDPOINT`: shared OTLP endpoint for traces and metrics.
- `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`: traces-only OTLP endpoint.
- `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT`: metrics-only OTLP endpoint.
- `OTEL_RESOURCE_ATTRIBUTES`: additional resource attributes, for example `deployment.environment=local`.
- `OTEL_SDK_DISABLED=true`: force telemetry off.

Emitted signals:

- HTTP server spans and HTTP client spans, including trace context propagation to the LLM proxy.
- Manual spans for CLI commands, chat turn start, SSE streaming, abort/cancel, OpenResponses proxy requests, stream consumption, and fake provider streaming.
- Counters for chat turns started/completed/cancelled/failed and streamed LLM/SSE events.
- Histograms for upstream LLM request duration and chat turn duration.

Do not attach prompt text, user messages, OAuth tokens, API keys, bearer tokens, cookies, session IDs, user IDs, turn IDs, model output, or raw LLM responses to telemetry.

## Fake OpenResponses Provider

The repo includes a deterministic fake OpenAI-compatible Responses API provider for tests and local development. Prefer `internal/llm/openresponses/fakeprovider.NewHandler()` in Go tests instead of hand-written happy-path SSE stubs. Keep custom `httptest` handlers for malformed streams, auth assertions, and narrow parser edge cases.
Expand Down
20 changes: 18 additions & 2 deletions cmd/fake-responses/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import (
"syscall"
"time"

"example.com/llm-chat-web/internal/buildinfo"
"example.com/llm-chat-web/internal/llm/openresponses/fakeprovider"
"example.com/llm-chat-web/internal/observability"
)

type fakeResponsesServer interface {
Expand All @@ -26,7 +28,7 @@ var (
newServer = func(addr string, opts fakeprovider.Options) fakeResponsesServer {
return &http.Server{
Addr: addr,
Handler: fakeprovider.NewHandlerWithOptions(opts),
Handler: observability.HTTPMiddleware(fakeprovider.NewHandlerWithOptions(opts)),
ReadHeaderTimeout: 5 * time.Second,
}
}
Expand All @@ -41,7 +43,7 @@ func main() {
exit(runCommand(os.Args[1:], os.Stderr))
}

func run(args []string, stderr io.Writer) int {
func run(args []string, stderr io.Writer) (code int) {
flags := flag.NewFlagSet("fake-responses", flag.ContinueOnError)
flags.SetOutput(stderr)
addr := flags.String("addr", ":8080", "address for the fake Responses API provider")
Expand All @@ -53,6 +55,20 @@ func run(args []string, stderr io.Writer) int {
return 2
}

shutdown, err := observability.Init(context.Background(), observability.FromEnv(buildinfo.Snapshot()))
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := shutdown(ctx); err != nil && code == 0 {
fmt.Fprintln(stderr, err)
code = 1
}
}()

server := newServer(*addr, fakeprovider.Options{StreamDelay: *streamDelay})
errc := make(chan error, 1)
go func() {
Expand Down
35 changes: 35 additions & 0 deletions cmd/fake-responses/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ func TestRunTreatsServerClosedAsSuccess(t *testing.T) {

func TestRunPassesStreamDelayOption(t *testing.T) {
server := &stubFakeResponsesServer{done: make(chan struct{})}
disableTelemetryEnv(t)
var gotAddr string
var gotOpts fakeprovider.Options
original := newServer
Expand Down Expand Up @@ -166,6 +167,24 @@ func TestRunPassesStreamDelayOption(t *testing.T) {
}
}

func TestRunStartsWithTelemetryDisabledByDefault(t *testing.T) {
server := &stubFakeResponsesServer{shutdownErr: nil}
withFakeResponseServer(t, server)
sigc := make(chan os.Signal, 1)
sigc <- os.Interrupt
withSignalChannel(t, sigc)
var stderr bytes.Buffer

code := run([]string{"--addr", "127.0.0.1:0"}, &stderr)

if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr = %q", code, stderr.String())
}
if server.shutdowns != 1 {
t.Fatalf("shutdown count = %d, want 1", server.shutdowns)
}
}

func TestDefaultFactories(t *testing.T) {
server := newServer("127.0.0.1:0", fakeprovider.Options{})
httpServer, ok := server.(*http.Server)
Expand All @@ -185,6 +204,7 @@ func TestDefaultFactories(t *testing.T) {

func withFakeResponseServer(t *testing.T, server *stubFakeResponsesServer) {
t.Helper()
disableTelemetryEnv(t)

if server.done == nil {
server.done = make(chan struct{})
Expand All @@ -198,6 +218,21 @@ func withFakeResponseServer(t *testing.T, server *stubFakeResponsesServer) {
})
}

func disableTelemetryEnv(t *testing.T) {
t.Helper()

for _, name := range []string{
"OTEL_SERVICE_NAME",
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_RESOURCE_ATTRIBUTES",
"OTEL_SDK_DISABLED",
} {
t.Setenv(name, "")
}
}

func withSignalChannel(t *testing.T, sigc chan os.Signal) {
t.Helper()

Expand Down
21 changes: 20 additions & 1 deletion cmd/pyttechat/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ package main

import (
"context"
"fmt"
"os"
"time"

"example.com/llm-chat-web/internal/buildinfo"
"example.com/llm-chat-web/internal/cli"
"example.com/llm-chat-web/internal/observability"
)

var (
Expand All @@ -13,5 +17,20 @@ var (
)

func main() {
exit(execute(context.Background(), os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
ctx := context.Background()
shutdown, err := observability.Init(ctx, observability.FromEnv(buildinfo.Snapshot()))
if err != nil {
fmt.Fprintln(os.Stderr, err)
exit(1)
return
}

code := execute(ctx, os.Args[1:], os.Stdin, os.Stdout, os.Stderr)
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if err := shutdown(shutdownCtx); err != nil && code == 0 {
fmt.Fprintln(os.Stderr, err)
code = 1
}
cancel()
exit(code)
}
16 changes: 16 additions & 0 deletions cmd/pyttechat/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
)

func TestMainDelegatesToCLIExitStatus(t *testing.T) {
disableTelemetryEnv(t)
originalArgs := os.Args
originalExit := exit
originalExecute := execute
Expand Down Expand Up @@ -41,3 +42,18 @@ func TestMainDelegatesToCLIExitStatus(t *testing.T) {
t.Fatalf("args = %#v, want version", gotArgs)
}
}

func disableTelemetryEnv(t *testing.T) {
t.Helper()

for _, name := range []string{
"OTEL_SERVICE_NAME",
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_RESOURCE_ATTRIBUTES",
"OTEL_SDK_DISABLED",
} {
t.Setenv(name, "")
}
}
25 changes: 25 additions & 0 deletions docs/otel-collector.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318

processors:
batch:

exporters:
debug:
verbosity: detailed

service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [debug]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [debug]
29 changes: 25 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,45 @@ require (
github.com/spf13/cobra v1.10.2
github.com/yuin/goldmark v1.8.2
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
golang.org/x/crypto v0.50.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0
go.opentelemetry.io/otel v1.44.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0
go.opentelemetry.io/otel/metric v1.44.0
go.opentelemetry.io/otel/sdk v1.44.0
go.opentelemetry.io/otel/sdk/metric v1.44.0
go.opentelemetry.io/otel/trace v1.44.0
golang.org/x/crypto v0.51.0
modernc.org/sqlite v1.50.1
)

require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dlclark/regexp2 v1.7.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/tools v0.44.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
Expand Down
Loading
Loading