diff --git a/apps/ai-observability/README.md b/apps/ai-observability/README.md index d2da41c8d..b741745e6 100644 --- a/apps/ai-observability/README.md +++ b/apps/ai-observability/README.md @@ -24,8 +24,9 @@ Each app exists to test one thing the others don't: - `openai-agents/python-travel-triage` — tracing processor; the SDK emits the tree - `vercel-ai/nextjs-support-chat` — per-request identity; framework bootstrap - `manual-capture/node-http-chat` — hand-built tree; must reuse the existing client +- `opentelemetry/go-weather` — Go; no wrapper SDK exists, so the posthog-go OTel bridge -The four weather apps implement the identical `get_weather` round trip from +The five weather apps implement the identical `get_weather` round trip from [Anthropic's tool-use docs](https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview#how-tool-use-works), so a diff between any two isolates one variable: the SDK, the language, or the conversation structure. @@ -37,7 +38,8 @@ conversation structure. `posthog_trace_id`, and `posthog_properties`, as shown in the docs. OTel is acceptable only where the same structure lands. Exceptions: `openai-agents` (tracing processor), `vercel-ai` (`experimental_telemetry`), - `manual-capture` (no SDK to wrap). + `manual-capture` (no SDK to wrap), + `opentelemetry/go-weather` (Go has no wrapper SDK; the posthog-go OTel bridge). - **Every app gets a session** — single-trace apps included. The graded property is **cardinality**: one id shared by the traces that belong together. A fresh id per call groups nothing and is worse than none. diff --git a/apps/ai-observability/opentelemetry/go-weather/README.md b/apps/ai-observability/opentelemetry/go-weather/README.md new file mode 100644 index 000000000..0b1c32562 --- /dev/null +++ b/apps/ai-observability/opentelemetry/go-weather/README.md @@ -0,0 +1,39 @@ +# wb-aio-opentelemetry-go-weather + +Weather assistant, official OpenAI Go SDK (`openai-go`), two-turn +conversation. PostHog-less fixture for `wizard ai-observability`. + +Go has no PostHog wrapper SDK. The expected mechanism is the +`github.com/posthog/posthog-go/otel` bridge: register its span processor on a +`TracerProvider`, then hand-author `gen_ai.*` spans around the model calls, +because no Go instrumentation library exists for `openai-go`. + +``` +thread_abc ← $ai_session_id (Conversation.ThreadID) +├─ Ask("weather in San Francisco?") ← trace +│ ├─ Chat.Completions.New (→ tool_calls) ← generation +│ ├─ get_weather ← span +│ └─ Chat.Completions.New (→ answer) ← generation +└─ Ask("How about Boston?") ← trace (same shape) +``` + +## Expected outcome + +- one session (`thread_abc`), two traces of + `generation → span(get_weather) → generation`, all on `user_123` +- `posthogotel.NewSpanProcessor` on a `TracerProvider`; each model call gets a + span with `gen_ai.*` attributes (operation, provider, model, messages, token + usage) so it lands as `$ai_generation` +- one OTel trace per `Ask`: a root span per turn that itself passes the AI + span filter (name or attribute key in the `gen_ai.` / `ai.` namespaces), + with the generations and the tool span as children +- the tool run captured as a child span with `gen_ai.*` tool attributes, not + left as a plain span the processor drops +- `$ai_session_id` spelled exactly, on every span in the thread; + `posthog.distinct_id` carries `user_123` +- flushed before exit (`ForceFlush` or `Shutdown` on the provider) +- `weather.go` and the tool loop untouched, including the `ToParam()` append + +Fail: importing a PostHog wrapper that does not exist in Go; spans that miss +the bridge's AI filter and never reach PostHog; a session id minted per turn; +one trace per model call; no flush. diff --git a/apps/ai-observability/opentelemetry/go-weather/go.mod b/apps/ai-observability/opentelemetry/go-weather/go.mod new file mode 100644 index 000000000..a30f3b06f --- /dev/null +++ b/apps/ai-observability/opentelemetry/go-weather/go.mod @@ -0,0 +1,12 @@ +module wb-aio-opentelemetry-go-weather + +go 1.25.0 + +require github.com/openai/openai-go/v3 v3.54.0 + +require ( + github.com/tidwall/gjson v1.19.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect +) diff --git a/apps/ai-observability/opentelemetry/go-weather/go.sum b/apps/ai-observability/opentelemetry/go-weather/go.sum new file mode 100644 index 000000000..7654042b7 --- /dev/null +++ b/apps/ai-observability/opentelemetry/go-weather/go.sum @@ -0,0 +1,48 @@ +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.0 h1:4gRPBpN1f6xt88yi4WR26m7XaD9OlWtVT6bWPdGUIok= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.0/go.mod h1:G7QVLxw1j1JVyrO1MA95S8m8HStaaleDZYTcfGgjB2o= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= +github.com/aws/aws-sdk-go-v2 v1.43.6 h1:RrmFcqCBxkJuf7g1axVo5krB4jM/AO8r5e5oujrgdoQ= +github.com/aws/aws-sdk-go-v2 v1.43.6/go.mod h1:tXpPM+v0D1lndmga+HqqLDIzUFJlEeR21aspVklHF00= +github.com/aws/aws-sdk-go-v2/config v1.32.37 h1:Ljl7LOJB6ym0liuEl0+TZ3d7f5I8MEZN1Cj9PINlj/g= +github.com/aws/aws-sdk-go-v2/config v1.32.37/go.mod h1:WJ7pe7ZPpmG8Q5kKS53zeypIV4FBGACxmte8Uc6SgUc= +github.com/aws/aws-sdk-go-v2/credentials v1.19.36 h1:84s5xMme6ENYEdKG8rsbSFFg/8+lbHBeM9QYSO0gnDk= +github.com/aws/aws-sdk-go-v2/credentials v1.19.36/go.mod h1:c46BLdagDLIswjgt+GeQOslXgeS0E6wCacs5yZbxPGk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.37 h1:b5tb+CZItBkydC7r3hTNdSO3pszG1R2EtnA+7TePQPk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.37/go.mod h1:ZQ+6SU9X0oz6+7MUCSswv9Mjci4eaqZr21HI2RVy/yA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37 h1:lznzIOvvbqjfe8UAaciCRJgBgJsxuTROKlhZuXQWfv8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37/go.mod h1:otfkzyfQeMMLZAqX59GSXTL3o22BR/l6HFaRzzbWSqA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37 h1:zCEORWo0eU0gDjG+IyApE/2B+ZGG1m+GU7B263XV8ds= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37/go.mod h1:i6c0PEl3TNOWxRbQ++KQcVenPWS/GoQeiklKhNuqzJ8= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38 h1:A3UAuCmx7LyUcrixBTzKJYYIUZ2yTvn6ZhT8PB+7APk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38/go.mod h1:1PDUYG9Z+JrbbsobsAZHjWOm9QBT/djiK3QbykTL5Z4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 h1:OvYZOB3qA6zvfdRFiRFRzVSiElMYrz3GdntkXZxlp1o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17/go.mod h1:JgR/2Ew50ACfIWau1oeMRX59tMtC0kM+PYQGEaT04cY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37 h1:a3D4AjrOrTrP8+d9ILBthqrElf0z1JNol09Xvnwcys8= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37/go.mod h1:ky0gTu+ukvUTuUKFIpp6Wid4oninrkCyvbFkVs0kpHM= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.6 h1:i68sFvXidKlkiSvI7d7Ilc1/UvW4CtBOaivH7jhG4fs= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.6/go.mod h1:/h7Obr9WTtzbjTHGASRQwLN7Bupw+TC3x8x7fyx39hE= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.6 h1:tpfGChmjUmv3W9WlRvy+stwKDTbFFdq8Zk9DbFPrfMU= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.6/go.mod h1:CSjiDzmG/lsKkTOYjbkM+duLmRlW+LOxD64Na44ijnI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.6 h1:49BBtY68A+KJCQ3a2F3eUe6ROsKucxUdfHKoqorc0wI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.6/go.mod h1:ptG2hbs7QltE1GcQY0MpS4bfrc51KCnBXUr7OT1EEfE= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.6 h1:JvExZWabChDM0qJAirQYGfOYo0ndT3edXj+fqSPNjkE= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.6/go.mod h1:XZcaQkV2cItp6yEkrwljyaPOf22RuX7T43jxap/FOmM= +github.com/aws/smithy-go v1.27.8 h1:FR0dxZfIlV7Z8eh2iHfIofdunw382XsDV3Mxt9nUvRY= +github.com/aws/smithy-go v1.27.8/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/openai/openai-go/v3 v3.54.0 h1:qeNvIpFB/wzX7pI8USN1eruvzCS+XqOZ8mx73lGF6Lg= +github.com/openai/openai-go/v3 v3.54.0/go.mod h1:ufI1+K+t0ijRB3gk8eztiw1crcDpsBuxRQL4sbLIrts= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= diff --git a/apps/ai-observability/opentelemetry/go-weather/main.go b/apps/ai-observability/opentelemetry/go-weather/main.go new file mode 100644 index 000000000..0b76c6ce5 --- /dev/null +++ b/apps/ai-observability/opentelemetry/go-weather/main.go @@ -0,0 +1,104 @@ +// Weather assistant on the official OpenAI Go SDK. The model may call the +// registered get_weather tool before answering, so one question is either one +// model call or two with a tool execution between them. +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" +) + +const model = "gpt-5-mini" + +var tools = []openai.ChatCompletionToolUnionParam{ + openai.ChatCompletionFunctionTool(openai.FunctionDefinitionParam{ + Name: "get_weather", + Description: openai.String("Get the current weather for a given location."), + Parameters: openai.FunctionParameters{ + "type": "object", + "properties": map[string]any{ + "location": map[string]string{ + "type": "string", + "description": "City and state, e.g. San Francisco, CA", + }, + }, + "required": []string{"location"}, + }, + }), +} + +// Conversation is one chat thread. Every question asked below belongs to it. +type Conversation struct { + UserID string + ThreadID string + messages []openai.ChatCompletionMessageParamUnion +} + +// Ask answers one question, running the tool if the model asks for it. +func (c *Conversation) Ask(ctx context.Context, client openai.Client, question string) (string, error) { + c.messages = append(c.messages, openai.UserMessage(question)) + + response, err := client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{ + Model: model, + Messages: c.messages, + Tools: tools, + ParallelToolCalls: openai.Bool(false), + }) + if err != nil { + return "", err + } + message := response.Choices[0].Message + + if len(message.ToolCalls) == 0 { + c.messages = append(c.messages, openai.AssistantMessage(message.Content)) + return message.Content, nil + } + + call := message.ToolCalls[0] + var args struct { + Location string `json:"location"` + } + if err := json.Unmarshal([]byte(call.Function.Arguments), &args); err != nil { + return "", err + } + result := getWeather(args.Location) + + c.messages = append(c.messages, message.ToParam()) + c.messages = append(c.messages, openai.ToolMessage(result, call.ID)) + + followup, err := client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{ + Model: model, + Messages: c.messages, + Tools: tools, + ParallelToolCalls: openai.Bool(false), + }) + if err != nil { + return "", err + } + answer := followup.Choices[0].Message.Content + c.messages = append(c.messages, openai.AssistantMessage(answer)) + return answer, nil +} + +func main() { + ctx := context.Background() + client := openai.NewClient(option.WithAPIKey(os.Getenv("OPENAI_API_KEY"))) + + thread := &Conversation{UserID: "user_123", ThreadID: "thread_abc"} + for _, question := range []string{ + "What's the weather in San Francisco?", + "How about Boston?", + } { + answer, err := thread.Ask(ctx, client, question) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println(answer) + } +} diff --git a/apps/ai-observability/opentelemetry/go-weather/weather.go b/apps/ai-observability/opentelemetry/go-weather/weather.go new file mode 100644 index 000000000..d3c9c0953 --- /dev/null +++ b/apps/ai-observability/opentelemetry/go-weather/weather.go @@ -0,0 +1,14 @@ +// Backing implementation for the get_weather tool. No model call involved. +package main + +var forecast = map[string]string{ + "San Francisco, CA": "15 degrees Celsius, partly cloudy", + "Boston, MA": "4 degrees Celsius, snow showers", +} + +func getWeather(location string) string { + if report, ok := forecast[location]; ok { + return report + } + return "No forecast on file for " + location + "." +}