diff --git a/.gitignore b/.gitignore index 27f1258..21a1703 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,6 @@ *.so *.tar.gz /release/ -sample-app # Test binary, built with `go test -c` *.test diff --git a/sample-app/generate_joke_workflow_example.go b/sample-app/generate_joke_workflow_example.go new file mode 100644 index 0000000..8fcc9e4 --- /dev/null +++ b/sample-app/generate_joke_workflow_example.go @@ -0,0 +1,297 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/sashabaranov/go-openai" + sdk "github.com/traceloop/go-openllmetry/traceloop-sdk" +) + +func createJoke(ctx context.Context, workflow *sdk.Workflow, client *openai.Client) (string, error) { + task := workflow.NewTask("joke_creation") + defer task.End() + + // Log prompt + prompt := sdk.Prompt{ + Vendor: "openai", + Mode: "chat", + Model: "gpt-3.5-turbo", + Messages: []sdk.Message{ + { + Index: 0, + Role: "user", + Content: "Tell me a joke about opentelemetry", + }, + }, + } + + llmSpan := task.LogPrompt(prompt) + + // Make API call + resp, err := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{ + Model: "gpt-3.5-turbo", + Messages: []openai.ChatCompletionMessage{ + { + Role: "user", + Content: "Tell me a joke about opentelemetry", + }, + }, + }) + if err != nil { + return "", fmt.Errorf("CreateChatCompletion error: %w", err) + } + + // Log completion + var completionMsgs []sdk.Message + for _, choice := range resp.Choices { + completionMsgs = append(completionMsgs, sdk.Message{ + Index: choice.Index, + Content: choice.Message.Content, + Role: choice.Message.Role, + }) + } + + llmSpan.LogCompletion(ctx, sdk.Completion{ + Model: resp.Model, + Messages: completionMsgs, + }, sdk.Usage{ + TotalTokens: resp.Usage.TotalTokens, + CompletionTokens: resp.Usage.CompletionTokens, + PromptTokens: resp.Usage.PromptTokens, + }) + + return resp.Choices[0].Message.Content, nil +} + +func translateJokeToPirate(ctx context.Context, workflow *sdk.Workflow, client *openai.Client, joke string) (string, error) { + // Log prompt + piratePrompt := fmt.Sprintf("Translate the below joke to pirate-like english:\n\n%s", joke) + prompt := sdk.Prompt{ + Vendor: "openai", + Mode: "chat", + Model: "gpt-3.5-turbo", + Messages: []sdk.Message{ + { + Index: 0, + Role: "user", + Content: piratePrompt, + }, + }, + } + + agent := workflow.NewAgent("joke_translation", map[string]string{ + "translation_type": "pirate", + }) + defer agent.End() + + llmSpan := agent.LogPrompt(prompt) + + // Make API call + resp, err := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{ + Model: "gpt-3.5-turbo", + Messages: []openai.ChatCompletionMessage{ + { + Role: "user", + Content: piratePrompt, + }, + }, + }) + if err != nil { + return "", fmt.Errorf("CreateChatCompletion error: %w", err) + } + + // Log completion + var completionMsgs []sdk.Message + for _, choice := range resp.Choices { + completionMsgs = append(completionMsgs, sdk.Message{ + Index: choice.Index, + Content: choice.Message.Content, + Role: choice.Message.Role, + }) + } + + llmSpan.LogCompletion(ctx, sdk.Completion{ + Model: resp.Model, + Messages: completionMsgs, + }, sdk.Usage{ + TotalTokens: resp.Usage.TotalTokens, + CompletionTokens: resp.Usage.CompletionTokens, + PromptTokens: resp.Usage.PromptTokens, + }) + + // Call history jokes tool + _, err = historyJokesTool(ctx, agent, client) + if err != nil { + fmt.Printf("Warning: history_jokes_tool error: %v\n", err) + } + + return resp.Choices[0].Message.Content, nil +} + +func historyJokesTool(ctx context.Context, agent *sdk.Agent, client *openai.Client) (string, error) { + // Log prompt + prompt := sdk.Prompt{ + Vendor: "openai", + Mode: "chat", + Model: "gpt-3.5-turbo", + Messages: []sdk.Message{ + { + Index: 0, + Role: "user", + Content: "get some history jokes", + }, + }, + } + + tool := agent.NewTool("history_jokes", "function", sdk.ToolFunction{ + Name: "history_jokes", + Description: "Get some history jokes", + Parameters: map[string]interface{}{}, + }, map[string]string{ + "user_id": "user_12345", + }) + defer tool.End() + + llmSpan := tool.LogPrompt(prompt) + + // Make API call + resp, err := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{ + Model: "gpt-3.5-turbo", + Messages: []openai.ChatCompletionMessage{ + { + Role: "user", + Content: "get some history jokes", + }, + }, + }) + if err != nil { + return "", fmt.Errorf("CreateChatCompletion error: %w", err) + } + + // Log completion + var completionMsgs []sdk.Message + for _, choice := range resp.Choices { + completionMsgs = append(completionMsgs, sdk.Message{ + Index: choice.Index, + Content: choice.Message.Content, + Role: choice.Message.Role, + }) + } + + llmSpan.LogCompletion(ctx, sdk.Completion{ + Model: resp.Model, + Messages: completionMsgs, + }, sdk.Usage{ + TotalTokens: resp.Usage.TotalTokens, + CompletionTokens: resp.Usage.CompletionTokens, + PromptTokens: resp.Usage.PromptTokens, + }) + + return resp.Choices[0].Message.Content, nil +} + +func generateSignature(ctx context.Context, workflow *sdk.Workflow, client *openai.Client, joke string) (string, error) { + task := workflow.NewTask("signature_generation") + defer task.End() + + signaturePrompt := "add a signature to the joke:\n\n" + joke + + // Log prompt + prompt := sdk.Prompt{ + Vendor: "openai", + Mode: "completion", + Model: "davinci-002", + Messages: []sdk.Message{ + { + Index: 0, + Role: "user", + Content: signaturePrompt, + }, + }, + } + + llmSpan := task.LogPrompt(prompt) + + // Make API call + resp, err := client.CreateCompletion(ctx, openai.CompletionRequest{ + Model: "davinci-002", + Prompt: signaturePrompt, + }) + if err != nil { + return "", fmt.Errorf("CreateCompletion error: %w", err) + } + + // Log completion + llmSpan.LogCompletion(ctx, sdk.Completion{ + Model: resp.Model, + Messages: []sdk.Message{ + { + Index: 0, + Role: "assistant", + Content: resp.Choices[0].Text, + }, + }, + }, sdk.Usage{ + TotalTokens: resp.Usage.TotalTokens, + CompletionTokens: resp.Usage.CompletionTokens, + PromptTokens: resp.Usage.PromptTokens, + }) + + return resp.Choices[0].Text, nil +} + +func runJokeWorkflow() { + ctx := context.Background() + + // Initialize Traceloop SDK + traceloop, err := sdk.NewClient(ctx, sdk.Config{ + APIKey: os.Getenv("TRACELOOP_API_KEY"), + }) + if err != nil { + fmt.Printf("NewClient error: %v\n", err) + return + } + defer func() { traceloop.Shutdown(ctx) }() + + client := openai.NewClient(os.Getenv("OPENAI_API_KEY")) + + // Create workflow + wf := traceloop.NewWorkflow(ctx, sdk.WorkflowAttributes{ + Name: "go-joke_generator", + AssociationProperties: map[string]string{ + "user_id": "user_12345", + "chat_id": "chat_1234", + }, + }) + defer wf.End() + + // Execute workflow steps + fmt.Println("Creating joke...") + engJoke, err := createJoke(ctx, wf, client) + if err != nil { + fmt.Printf("Error creating joke: %v\n", err) + return + } + fmt.Printf("\nEnglish joke:\n%s\n\n", engJoke) + + fmt.Println("Translating to pirate...") + pirateJoke, err := translateJokeToPirate(ctx, wf, client, engJoke) + if err != nil { + fmt.Printf("Error translating joke: %v\n", err) + return + } + fmt.Printf("\nPirate joke:\n%s\n\n", pirateJoke) + + fmt.Println("Generating signature...") + signature, err := generateSignature(ctx, wf, client, pirateJoke) + if err != nil { + fmt.Printf("Error generating signature: %v\n", err) + return + } + + // Combine result + result := pirateJoke + "\n\n" + signature + fmt.Printf("\n=== Final Result ===\n%s\n", result) +} diff --git a/sample-app/main.go b/sample-app/main.go index 363adba..eb49b3a 100644 --- a/sample-app/main.go +++ b/sample-app/main.go @@ -22,7 +22,17 @@ func main() { runToolCallingExample() return } - + + if len(os.Args) > 1 && os.Args[1] == "recipe-agent" { + runRecipeAgent() + return + } + + if len(os.Args) > 1 && os.Args[1] == "joke-workflow" { + runJokeWorkflow() + return + } + // Default to workflow example using prompt registry workflowExample() } @@ -61,7 +71,8 @@ func workflowExample() { } // Log the prompt - llmSpan, err := traceloop.LogPrompt( + workflowName := "example-workflow" + llmSpan := traceloop.LogPrompt( ctx, sdk.Prompt{ Vendor: "openai", @@ -69,8 +80,8 @@ func workflowExample() { Model: request.Model, Messages: promptMsgs, }, - sdk.WorkflowAttributes{ - Name: "example-workflow", + sdk.ContextAttributes{ + WorkflowName: &workflowName, AssociationProperties: map[string]string{ "user_id": "demo-user", }, @@ -103,7 +114,7 @@ func workflowExample() { } // Log the completion - err = llmSpan.LogCompletion(ctx, sdk.Completion{ + llmSpan.LogCompletion(ctx, sdk.Completion{ Model: resp.Model, Messages: completionMsgs, }, sdk.Usage{ @@ -111,10 +122,6 @@ func workflowExample() { CompletionTokens: resp.Usage.CompletionTokens, PromptTokens: resp.Usage.PromptTokens, }) - if err != nil { - fmt.Printf("LogCompletion error: %v\n", err) - return - } fmt.Println(resp.Choices[0].Message.Content) -} \ No newline at end of file +} diff --git a/sample-app/recipe_agent_example.go b/sample-app/recipe_agent_example.go new file mode 100644 index 0000000..efd003d --- /dev/null +++ b/sample-app/recipe_agent_example.go @@ -0,0 +1,328 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/sashabaranov/go-openai" + semconvai "github.com/traceloop/go-openllmetry/semconv-ai" + sdk "github.com/traceloop/go-openllmetry/traceloop-sdk" +) + +var associationProperties = map[string]string{ + "user_id": "user_67890", +} + +var abTest = &semconvai.ABTest{ + VariantKeys: map[string]bool{ + "variant_a": false, + "variant_b": true, + }, +} + +// ingredientValidatorTool validates that requested ingredients are available/safe +func ingredientValidatorTool(ctx context.Context, agent *sdk.Agent, client *openai.Client, ingredients string) (string, error) { + tool := agent.NewTool("ingredient_validator", "function", sdk.ToolFunction{ + Name: "ingredient_validator", + Description: "Validates that requested ingredients are available and safe to use", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "ingredients": map[string]string{ + "type": "string", + "description": "Comma-separated list of ingredients to validate", + }, + }, + }, + }, associationProperties) + defer tool.End() + + prompt := sdk.Prompt{ + Vendor: "openai", + Mode: "chat", + Model: "gpt-3.5-turbo", + Messages: []sdk.Message{ + { + Index: 0, + Role: "user", + Content: fmt.Sprintf("Validate these ingredients are commonly available and safe: %s. Respond with 'Valid' or list any concerns.", ingredients), + }, + }, + } + + llmSpan := tool.LogPrompt(prompt) + + resp, err := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{ + Model: "gpt-3.5-turbo", + Messages: []openai.ChatCompletionMessage{ + { + Role: "user", + Content: prompt.Messages[0].Content, + }, + }, + }) + if err != nil { + return "", fmt.Errorf("CreateChatCompletion error: %w", err) + } + + var completionMsgs []sdk.Message + for _, choice := range resp.Choices { + completionMsgs = append(completionMsgs, sdk.Message{ + Index: choice.Index, + Content: choice.Message.Content, + Role: choice.Message.Role, + }) + } + + llmSpan.LogCompletion(ctx, sdk.Completion{ + Model: resp.Model, + Messages: completionMsgs, + }, sdk.Usage{ + TotalTokens: resp.Usage.TotalTokens, + CompletionTokens: resp.Usage.CompletionTokens, + PromptTokens: resp.Usage.PromptTokens, + }) + + return resp.Choices[0].Message.Content, nil +} + +// nutritionCalculatorTool calculates nutritional information for the recipe +func nutritionCalculatorTool(ctx context.Context, agent *sdk.Agent, client *openai.Client, recipe string) (string, error) { + tool := agent.NewTool("nutrition_calculator", "function", sdk.ToolFunction{ + Name: "nutrition_calculator", + Description: "Calculates estimated nutritional information for a recipe", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "recipe": map[string]string{ + "type": "string", + "description": "The recipe description", + }, + }, + }, + }, associationProperties) + defer tool.End() + + prompt := sdk.Prompt{ + Vendor: "openai", + Mode: "chat", + Model: "gpt-3.5-turbo", + Messages: []sdk.Message{ + { + Index: 0, + Role: "user", + Content: fmt.Sprintf("Estimate the nutritional information (calories, protein, carbs, fat) per serving for this recipe: %s", recipe), + }, + }, + } + + llmSpan := tool.LogPrompt(prompt) + + resp, err := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{ + Model: "gpt-3.5-turbo", + Messages: []openai.ChatCompletionMessage{ + { + Role: "user", + Content: prompt.Messages[0].Content, + }, + }, + }) + if err != nil { + return "", fmt.Errorf("CreateChatCompletion error: %w", err) + } + + var completionMsgs []sdk.Message + for _, choice := range resp.Choices { + completionMsgs = append(completionMsgs, sdk.Message{ + Index: choice.Index, + Content: choice.Message.Content, + Role: choice.Message.Role, + }) + } + + llmSpan.LogCompletion(ctx, sdk.Completion{ + Model: resp.Model, + Messages: completionMsgs, + }, sdk.Usage{ + TotalTokens: resp.Usage.TotalTokens, + CompletionTokens: resp.Usage.CompletionTokens, + PromptTokens: resp.Usage.PromptTokens, + }) + + return resp.Choices[0].Message.Content, nil +} + +// cookingTimeEstimatorTool estimates preparation and cooking time +func cookingTimeEstimatorTool(ctx context.Context, agent *sdk.Agent, client *openai.Client, recipe string) (string, error) { + tool := agent.NewTool("cooking_time_estimator", "function", sdk.ToolFunction{ + Name: "cooking_time_estimator", + Description: "Estimates preparation and cooking time based on recipe complexity", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "recipe": map[string]string{ + "type": "string", + "description": "The recipe description", + }, + }, + }, + }, associationProperties) + defer tool.End() + + prompt := sdk.Prompt{ + Vendor: "openai", + Mode: "chat", + Model: "gpt-3.5-turbo", + Messages: []sdk.Message{ + { + Index: 0, + Role: "user", + Content: fmt.Sprintf("Estimate the preparation time and cooking time for this recipe: %s", recipe), + }, + }, + } + + llmSpan := tool.LogPrompt(prompt) + + resp, err := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{ + Model: "gpt-3.5-turbo", + Messages: []openai.ChatCompletionMessage{ + { + Role: "user", + Content: prompt.Messages[0].Content, + }, + }, + }) + if err != nil { + return "", fmt.Errorf("CreateChatCompletion error: %w", err) + } + + var completionMsgs []sdk.Message + for _, choice := range resp.Choices { + completionMsgs = append(completionMsgs, sdk.Message{ + Index: choice.Index, + Content: choice.Message.Content, + Role: choice.Message.Role, + }) + } + + llmSpan.LogCompletion(ctx, sdk.Completion{ + Model: resp.Model, + Messages: completionMsgs, + }, sdk.Usage{ + TotalTokens: resp.Usage.TotalTokens, + CompletionTokens: resp.Usage.CompletionTokens, + PromptTokens: resp.Usage.PromptTokens, + }) + + return resp.Choices[0].Message.Content, nil +} + +func runRecipeAgent() { + ctx := context.Background() + + // Initialize Traceloop SDK + traceloop, err := sdk.NewClient(ctx, sdk.Config{ + APIKey: os.Getenv("TRACELOOP_API_KEY"), + }) + if err != nil { + fmt.Printf("NewClient error: %v\n", err) + return + } + defer func() { traceloop.Shutdown(ctx) }() + + client := openai.NewClient(os.Getenv("OPENAI_API_KEY")) + + // Create standalone agent with association properties + agent := traceloop.NewAgent(ctx, "recipe_generator", sdk.AgentAttributes{ + Name: "recipe_generator", + AssociationProperties: associationProperties, + ABTest: abTest, + }) + defer agent.End() + + userRequest := "Create a healthy pasta dish with vegetables" + fmt.Printf("User request: %s\n\n", userRequest) + + // Agent generates initial recipe + fmt.Println("Generating recipe...") + recipePrompt := sdk.Prompt{ + Vendor: "openai", + Mode: "chat", + Model: "gpt-3.5-turbo", + Messages: []sdk.Message{ + { + Index: 0, + Role: "user", + Content: fmt.Sprintf("Create a detailed recipe for: %s. Include ingredients and instructions.", userRequest), + }, + }, + } + + llmSpan := agent.LogPrompt(recipePrompt) + + resp, err := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{ + Model: "gpt-3.5-turbo", + Messages: []openai.ChatCompletionMessage{ + { + Role: "user", + Content: recipePrompt.Messages[0].Content, + }, + }, + }) + if err != nil { + fmt.Printf("Error generating recipe: %v\n", err) + return + } + + var completionMsgs []sdk.Message + for _, choice := range resp.Choices { + completionMsgs = append(completionMsgs, sdk.Message{ + Index: choice.Index, + Content: choice.Message.Content, + Role: choice.Message.Role, + }) + } + + llmSpan.LogCompletion(ctx, sdk.Completion{ + Model: resp.Model, + Messages: completionMsgs, + }, sdk.Usage{ + TotalTokens: resp.Usage.TotalTokens, + CompletionTokens: resp.Usage.CompletionTokens, + PromptTokens: resp.Usage.PromptTokens, + }) + + recipe := resp.Choices[0].Message.Content + fmt.Printf("\nGenerated Recipe:\n%s\n\n", recipe) + + // Tool 1: Validate ingredients + fmt.Println("Validating ingredients...") + validation, err := ingredientValidatorTool(ctx, agent, client, "pasta, tomatoes, spinach, garlic, olive oil") + if err != nil { + fmt.Printf("Warning: ingredient validation error: %v\n", err) + } else { + fmt.Printf("Validation Result: %s\n\n", validation) + } + + // Tool 2: Calculate nutrition + fmt.Println("Calculating nutrition...") + nutrition, err := nutritionCalculatorTool(ctx, agent, client, recipe) + if err != nil { + fmt.Printf("Warning: nutrition calculation error: %v\n", err) + } else { + fmt.Printf("Nutrition Info:\n%s\n\n", nutrition) + } + + // Tool 3: Estimate cooking time + fmt.Println("Estimating cooking time...") + cookingTime, err := cookingTimeEstimatorTool(ctx, agent, client, recipe) + if err != nil { + fmt.Printf("Warning: cooking time estimation error: %v\n", err) + } else { + fmt.Printf("Time Estimate:\n%s\n\n", cookingTime) + } + + fmt.Println("=== Recipe Agent Complete ===") +} diff --git a/sample-app/tool_calling.go b/sample-app/tool_calling.go index f654899..6924786 100644 --- a/sample-app/tool_calling.go +++ b/sample-app/tool_calling.go @@ -81,21 +81,18 @@ func runToolCallingExample() { Tools: tools, } - workflowAttrs := sdk.WorkflowAttributes{ - Name: "tool-calling-example", + workflowName := "tool-calling-example" + contextAttrs := sdk.ContextAttributes{ + WorkflowName: &workflowName, AssociationProperties: map[string]string{ "user_id": "demo-user", }, } fmt.Printf("User: %s\n", userPrompt) - + // Log the prompt - llmSpan, err := traceloop.LogPrompt(ctx, prompt, workflowAttrs) - if err != nil { - fmt.Printf("Error logging prompt: %v\n", err) - return - } + llmSpan := traceloop.LogPrompt(ctx, prompt, contextAttrs) // Make API call to OpenAI startTime := time.Now() @@ -175,11 +172,7 @@ func runToolCallingExample() { PromptTokens: int(resp.Usage.PromptTokens), } - err = llmSpan.LogCompletion(ctx, completion, usage) - if err != nil { - fmt.Printf("Error logging completion: %v\n", err) - return - } + llmSpan.LogCompletion(ctx, completion, usage) // If tool calls were made, execute them if len(resp.Choices[0].Message.ToolCalls) > 0 { diff --git a/sample-app/workflow_example.go b/sample-app/workflow_example.go index 9ee3638..a0b498f 100644 --- a/sample-app/workflow_example.go +++ b/sample-app/workflow_example.go @@ -47,7 +47,7 @@ func workflowMain() { }) } - llmSpan, err := factGenTask.LogPrompt( + llmSpan := factGenTask.LogPrompt( tlp.Prompt{ Vendor: "openai", Mode: "chat", @@ -55,10 +55,6 @@ func workflowMain() { Messages: promptMsgs, }, ) - if err != nil { - fmt.Printf("LogPrompt error: %v\n", err) - return - } client := openai.NewClient(os.Getenv("OPENAI_API_KEY")) resp, err := client.CreateChatCompletion( @@ -91,7 +87,7 @@ func workflowMain() { someOtherTask := wf.NewTask("some_other_task") defer someOtherTask.End() - otherPrompt, _ := someOtherTask.LogPrompt(tlp.Prompt{ + otherPrompt := someOtherTask.LogPrompt(tlp.Prompt{ Vendor: "openai", Mode: "chat", Model: request.Model, diff --git a/semconv-ai/attributes.go b/semconv-ai/attributes.go index 78103c2..fac64f7 100644 --- a/semconv-ai/attributes.go +++ b/semconv-ai/attributes.go @@ -23,6 +23,7 @@ const ( LLMCompletions = attribute.Key("llm.completions") LLMChatStopSequence = attribute.Key("llm.chat.stop_sequences") LLMRequestFunctions = attribute.Key("llm.request.functions") + LLMAgentName = attribute.Key("llm.agent.name") // Vector DB VectorDBVendor = attribute.Key("vector_db.vendor") diff --git a/semconv-ai/types.go b/semconv-ai/types.go new file mode 100644 index 0000000..c73bc03 --- /dev/null +++ b/semconv-ai/types.go @@ -0,0 +1,15 @@ +package semconvai + +type SpanKind string + +const ( + SpanKindTool SpanKind = "tool" + SpanKindAgent SpanKind = "agent" + SpanKindTask SpanKind = "task" + SpanKindWorkflow SpanKind = "workflow" +) + +// The variant that is active will be added to the trace. +type ABTest struct { + VariantKeys map[string]bool `json:"variant_keys"` +} diff --git a/traceloop-sdk/agent.go b/traceloop-sdk/agent.go new file mode 100644 index 0000000..f667a56 --- /dev/null +++ b/traceloop-sdk/agent.go @@ -0,0 +1,76 @@ +package traceloop + +import ( + "context" + "fmt" + + semconvai "github.com/traceloop/go-openllmetry/semconv-ai" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +type Agent struct { + sdk *Traceloop + workflow *Workflow + ctx context.Context + Attributes AgentAttributes `json:"agent_attributes"` +} + +func (agent *Agent) End() { + trace.SpanFromContext(agent.ctx).End() +} + +func (agent *Agent) LogPrompt(prompt Prompt) LLMSpan { + // Merge workflow and agent association properties + contextAttrs := ContextAttributes{ + AgentName: &agent.Attributes.Name, + AssociationProperties: make(map[string]string), + } + + // Start with workflow properties if available + if agent.workflow != nil { + contextAttrs.WorkflowName = &agent.workflow.Attributes.Name + for key, value := range agent.workflow.Attributes.AssociationProperties { + contextAttrs.AssociationProperties[key] = value + } + } + + // Agent properties override workflow properties + for key, value := range agent.Attributes.AssociationProperties { + contextAttrs.AssociationProperties[key] = value + } + + return agent.sdk.LogPrompt(agent.ctx, prompt, contextAttrs) +} + +func (agent *Agent) NewTool(name string, toolType string, toolFunction ToolFunction, associationProperties map[string]string) *Tool { + toolCtx, span := agent.sdk.getTracer().Start(agent.ctx, fmt.Sprintf("%s.tool", name)) + attrs := []attribute.KeyValue{ + semconvai.LLMAgentName.String(agent.Attributes.Name), + semconvai.TraceloopSpanKind.String(string(semconvai.SpanKindTool)), + semconvai.TraceloopEntityName.String(name), + } + + if agent.workflow != nil { + attrs = append(attrs, semconvai.TraceloopWorkflowName.String(agent.workflow.Attributes.Name)) + } + + for key, value := range agent.Attributes.AssociationProperties { + attrs = append(attrs, attribute.String("traceloop.association.properties."+key, value)) + } + + // Add tool-specific association properties + for key, value := range associationProperties { + attrs = append(attrs, attribute.String("traceloop.association.properties."+key, value)) + } + + span.SetAttributes(attrs...) + + return &Tool{ + agent: *agent, + ctx: toolCtx, + Name: name, + Type: toolType, + Function: toolFunction, + } +} diff --git a/traceloop-sdk/llm_span.go b/traceloop-sdk/llm_span.go new file mode 100644 index 0000000..2dd91a6 --- /dev/null +++ b/traceloop-sdk/llm_span.go @@ -0,0 +1,34 @@ +package traceloop + +import ( + "context" + + semconvai "github.com/traceloop/go-openllmetry/semconv-ai" + apitrace "go.opentelemetry.io/otel/trace" +) + +type LLMSpan struct { + span apitrace.Span +} + +func (llmSpan *LLMSpan) LogPrompt(ctx context.Context, prompt Prompt) { + llmSpan.span.SetAttributes( + semconvai.LLMRequestModel.String(prompt.Model), + semconvai.LLMRequestType.String(prompt.Mode), + ) + + setMessagesAttribute(llmSpan.span, "llm.prompts", prompt.Messages) +} + +func (llmSpan *LLMSpan) LogCompletion(ctx context.Context, completion Completion, usage Usage) { + llmSpan.span.SetAttributes( + semconvai.LLMResponseModel.String(completion.Model), + semconvai.LLMUsageTotalTokens.Int(usage.TotalTokens), + semconvai.LLMUsageCompletionTokens.Int(usage.CompletionTokens), + semconvai.LLMUsagePromptTokens.Int(usage.PromptTokens), + ) + + setMessagesAttribute(llmSpan.span, "llm.completions", completion.Messages) + + defer llmSpan.span.End() +} diff --git a/traceloop-sdk/sdk.go b/traceloop-sdk/sdk.go index d697072..9c36f85 100644 --- a/traceloop-sdk/sdk.go +++ b/traceloop-sdk/sdk.go @@ -29,10 +29,6 @@ type Traceloop struct { http.Client } -type LLMSpan struct { - span apitrace.Span -} - func NewClient(ctx context.Context, config Config) (*Traceloop, error) { instance := Traceloop{ config: config, @@ -144,8 +140,45 @@ func (instance *Traceloop) getTracer() apitrace.Tracer { return (*instance.tracerProvider).Tracer(instance.tracerName()) } +// NewAgent creates a standalone agent (without a workflow) +func (instance *Traceloop) NewAgent(ctx context.Context, name string, agentAttrs AgentAttributes) *Agent { + aCtx, span := instance.getTracer().Start(ctx, fmt.Sprintf("%s.agent", name), apitrace.WithNewRoot()) + + attrs := []attribute.KeyValue{ + semconvai.TraceloopSpanKind.String(string(semconvai.SpanKindAgent)), + semconvai.TraceloopEntityName.String(name), + semconvai.LLMAgentName.String(name), + } + + if agentAttrs.ABTest != nil { + if agentAttrs.AssociationProperties == nil { + agentAttrs.AssociationProperties = make(map[string]string) + } + for key, activeVariant := range agentAttrs.ABTest.VariantKeys { + if activeVariant { + agentAttrs.AssociationProperties["ab_testing_variant"] = key + break + } + } + } + + // Add association properties if provided + for key, value := range agentAttrs.AssociationProperties { + attrs = append(attrs, attribute.String("traceloop.association.properties."+key, value)) + } + + span.SetAttributes(attrs...) + + return &Agent{ + sdk: instance, + workflow: nil, + ctx: aCtx, + Attributes: agentAttrs, + } +} + // New workflow-based API -func (instance *Traceloop) LogPrompt(ctx context.Context, prompt Prompt, workflowAttrs WorkflowAttributes) (LLMSpan, error) { +func (instance *Traceloop) LogPrompt(ctx context.Context, prompt Prompt, contextAttrs ContextAttributes) LLMSpan { spanName := fmt.Sprintf("%s.%s", prompt.Vendor, prompt.Mode) _, span := instance.getTracer().Start(ctx, spanName) @@ -153,11 +186,18 @@ func (instance *Traceloop) LogPrompt(ctx context.Context, prompt Prompt, workflo semconvai.LLMVendor.String(prompt.Vendor), semconvai.LLMRequestModel.String(prompt.Model), semconvai.LLMRequestType.String(prompt.Mode), - semconvai.TraceloopWorkflowName.String(workflowAttrs.Name), } - // Add association properties if provided - for key, value := range workflowAttrs.AssociationProperties { + if contextAttrs.WorkflowName != nil { + attrs = append(attrs, semconvai.TraceloopWorkflowName.String(*contextAttrs.WorkflowName)) + } + + if contextAttrs.AgentName != nil { + attrs = append(attrs, semconvai.LLMAgentName.String(*contextAttrs.AgentName)) + } + + // Add association properties + for key, value := range contextAttrs.AssociationProperties { attrs = append(attrs, attribute.String("traceloop.association.properties."+key, value)) } @@ -167,21 +207,53 @@ func (instance *Traceloop) LogPrompt(ctx context.Context, prompt Prompt, workflo return LLMSpan{ span: span, - }, nil + } } -func (llmSpan *LLMSpan) LogCompletion(ctx context.Context, completion Completion, usage Usage) error { - llmSpan.span.SetAttributes( - semconvai.LLMResponseModel.String(completion.Model), - semconvai.LLMUsageTotalTokens.Int(usage.TotalTokens), - semconvai.LLMUsageCompletionTokens.Int(usage.CompletionTokens), - semconvai.LLMUsagePromptTokens.Int(usage.PromptTokens), - ) +// LogToolCall logs a tool call with the specified name +func (instance *Traceloop) LogToolCall(ctx context.Context, attrs ToolCallAttributes, workflowAttrs WorkflowAttributes) LLMSpan { + spanName := fmt.Sprintf("%s.tool", attrs.Name) + _, span := instance.getTracer().Start(ctx, spanName) - setMessagesAttribute(llmSpan.span, "llm.completions", completion.Messages) + spanAttrs := []attribute.KeyValue{ + semconvai.TraceloopWorkflowName.String(workflowAttrs.Name), + semconvai.TraceloopSpanKind.String(string(semconvai.SpanKindTool)), + semconvai.TraceloopEntityName.String(attrs.Name), + } - defer llmSpan.span.End() - return nil + // Add association properties if provided + for key, value := range workflowAttrs.AssociationProperties { + spanAttrs = append(spanAttrs, attribute.String("traceloop.association.properties."+key, value)) + } + + span.SetAttributes(spanAttrs...) + + return LLMSpan{ + span: span, + } +} + +// LogAgent logs an agent with the specified name +func (instance *Traceloop) LogAgent(ctx context.Context, attrs AgentAttributes, workflowAttrs WorkflowAttributes) LLMSpan { + spanName := fmt.Sprintf("%s.agent", attrs.Name) + _, span := instance.getTracer().Start(ctx, spanName) + + spanAttrs := []attribute.KeyValue{ + semconvai.TraceloopWorkflowName.String(workflowAttrs.Name), + semconvai.TraceloopSpanKind.String(string(semconvai.SpanKindAgent)), + semconvai.LLMAgentName.String(attrs.Name), + } + + // Add association properties if provided + for key, value := range workflowAttrs.AssociationProperties { + spanAttrs = append(spanAttrs, attribute.String("traceloop.association.properties."+key, value)) + } + + span.SetAttributes(spanAttrs...) + + return LLMSpan{ + span: span, + } } func (instance *Traceloop) Shutdown(ctx context.Context) { diff --git a/traceloop-sdk/sdk_test.go b/traceloop-sdk/sdk_test.go index 5b53763..8f845f2 100644 --- a/traceloop-sdk/sdk_test.go +++ b/traceloop-sdk/sdk_test.go @@ -70,10 +70,10 @@ func TestLogPromptSpanAttributes(t *testing.T) { } // Log the prompt using new workflow API - llmSpan, err := tl.LogPrompt(context.Background(), prompt, workflowAttrs) - if err != nil { - t.Fatalf("LogPrompt failed: %v", err) - } + llmSpan := tl.LogPrompt(context.Background(), prompt, ContextAttributes{ + WorkflowName: &workflowAttrs.Name, + AssociationProperties: workflowAttrs.AssociationProperties, + }) // Log completion with tool calls completion := Completion{ @@ -103,10 +103,8 @@ func TestLogPromptSpanAttributes(t *testing.T) { PromptTokens: 82, } - err = llmSpan.LogCompletion(context.Background(), completion, usage) - if err != nil { - t.Fatalf("LogCompletion failed: %v", err) - } + llmSpan.LogCompletion(context.Background(), completion, usage) + // Get the recorded spans spans := exporter.GetSpans() diff --git a/traceloop-sdk/task.go b/traceloop-sdk/task.go new file mode 100644 index 0000000..bb02e96 --- /dev/null +++ b/traceloop-sdk/task.go @@ -0,0 +1,25 @@ +package traceloop + +import ( + "context" + + "go.opentelemetry.io/otel/trace" +) + +type Task struct { + workflow *Workflow + ctx context.Context + Name string `json:"name"` +} + +func (task *Task) End() { + trace.SpanFromContext(task.ctx).End() +} + +func (task *Task) LogPrompt(prompt Prompt) LLMSpan { + contextAttrs := ContextAttributes{ + WorkflowName: &task.workflow.Attributes.Name, + AssociationProperties: task.workflow.Attributes.AssociationProperties, + } + return task.workflow.sdk.LogPrompt(task.ctx, prompt, contextAttrs) +} diff --git a/traceloop-sdk/tool.go b/traceloop-sdk/tool.go new file mode 100644 index 0000000..c4063a9 --- /dev/null +++ b/traceloop-sdk/tool.go @@ -0,0 +1,41 @@ +package traceloop + +import ( + "context" + + "go.opentelemetry.io/otel/trace" +) + +type Tool struct { + agent Agent + ctx context.Context + Name string `json:"name"` + Type string `json:"type"` + Function ToolFunction `json:"function,omitempty"` +} + +func (tool *Tool) End() { + trace.SpanFromContext(tool.ctx).End() +} + +func (tool *Tool) LogPrompt(prompt Prompt) LLMSpan { + // Merge workflow and agent association properties + contextAttrs := ContextAttributes{ + AssociationProperties: make(map[string]string), + } + + // Start with workflow properties if available + if tool.agent.workflow != nil { + contextAttrs.WorkflowName = &tool.agent.workflow.Attributes.Name + for key, value := range tool.agent.workflow.Attributes.AssociationProperties { + contextAttrs.AssociationProperties[key] = value + } + } + + // Agent properties override workflow properties + for key, value := range tool.agent.Attributes.AssociationProperties { + contextAttrs.AssociationProperties[key] = value + } + + return tool.agent.sdk.LogPrompt(tool.ctx, prompt, contextAttrs) +} diff --git a/traceloop-sdk/tracing_types.go b/traceloop-sdk/tracing_types.go index f4efa32..d3ac056 100644 --- a/traceloop-sdk/tracing_types.go +++ b/traceloop-sdk/tracing_types.go @@ -1,5 +1,7 @@ package traceloop +import "github.com/traceloop/go-openllmetry/semconv-ai" + type Message struct { Index int `json:"index"` Role string `json:"role"` @@ -28,6 +30,13 @@ type Completion struct { type WorkflowAttributes struct { Name string `json:"workflow_name"` AssociationProperties map[string]string `json:"association_properties"` + ABTest *semconvai.ABTest `json:"ab_test"` +} + +type ContextAttributes struct { + WorkflowName *string `json:"workflow_name,omitempty"` + AgentName *string `json:"agent_name,omitempty"` + AssociationProperties map[string]string `json:"association_properties,omitempty"` } type Usage struct { @@ -42,11 +51,6 @@ type ToolFunction struct { Parameters interface{} `json:"parameters"` } -type Tool struct { - Type string `json:"type"` - Function ToolFunction `json:"function,omitempty"` -} - type ToolCall struct { ID string `json:"id"` Type string `json:"type"` @@ -57,3 +61,13 @@ type ToolCallFunction struct { Name string `json:"name"` Arguments string `json:"arguments"` } + +type ToolCallAttributes struct { + Name string `json:"name"` +} + +type AgentAttributes struct { + Name string `json:"agent_name"` + AssociationProperties map[string]string `json:"association_properties"` + ABTest *semconvai.ABTest `json:"ab_test"` +} diff --git a/traceloop-sdk/workflow.go b/traceloop-sdk/workflow.go index 501a03a..f563aac 100644 --- a/traceloop-sdk/workflow.go +++ b/traceloop-sdk/workflow.go @@ -3,8 +3,10 @@ package traceloop import ( "context" "fmt" + "maps" semconvai "github.com/traceloop/go-openllmetry/semconv-ai" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) @@ -14,21 +16,24 @@ type Workflow struct { Attributes WorkflowAttributes `json:"workflow_attributes"` } -type Task struct { - workflow *Workflow - ctx context.Context - Name string `json:"name"` -} - func (instance *Traceloop) NewWorkflow(ctx context.Context, attrs WorkflowAttributes) *Workflow { wCtx, span := instance.getTracer().Start(ctx, fmt.Sprintf("%s.workflow", attrs.Name), trace.WithNewRoot()) span.SetAttributes( semconvai.TraceloopWorkflowName.String(attrs.Name), - semconvai.TraceloopSpanKind.String("workflow"), + semconvai.TraceloopSpanKind.String(string(semconvai.SpanKindWorkflow)), semconvai.TraceloopEntityName.String(attrs.Name), ) + if attrs.ABTest != nil { + for key, activeVariant := range attrs.ABTest.VariantKeys { + if activeVariant { + span.SetAttributes(attribute.String("traceloop.association.properties.ab_testing_variant", key)) + break + } + } + } + return &Workflow{ sdk: instance, ctx: wCtx, @@ -40,8 +45,12 @@ func (workflow *Workflow) End() { trace.SpanFromContext(workflow.ctx).End() } -func (workflow *Workflow) LogPrompt(prompt Prompt) (LLMSpan, error) { - return workflow.sdk.LogPrompt(workflow.ctx, prompt, workflow.Attributes) +func (workflow *Workflow) LogPrompt(prompt Prompt) LLMSpan { + contextAttrs := ContextAttributes{ + WorkflowName: &workflow.Attributes.Name, + AssociationProperties: workflow.Attributes.AssociationProperties, + } + return workflow.sdk.LogPrompt(workflow.ctx, prompt, contextAttrs) } func (workflow *Workflow) NewTask(name string) *Task { @@ -49,7 +58,7 @@ func (workflow *Workflow) NewTask(name string) *Task { span.SetAttributes( semconvai.TraceloopWorkflowName.String(workflow.Attributes.Name), - semconvai.TraceloopSpanKind.String("task"), + semconvai.TraceloopSpanKind.String(string(semconvai.SpanKindTask)), semconvai.TraceloopEntityName.String(name), ) @@ -60,10 +69,38 @@ func (workflow *Workflow) NewTask(name string) *Task { } } -func (task *Task) End() { - trace.SpanFromContext(task.ctx).End() -} +func (workflow *Workflow) NewAgent(name string, associationProperties map[string]string) *Agent { + aCtx, span := workflow.sdk.getTracer().Start(workflow.ctx, fmt.Sprintf("%s.agent", name)) -func (task *Task) LogPrompt(prompt Prompt) (LLMSpan, error) { - return task.workflow.sdk.LogPrompt(task.ctx, prompt, task.workflow.Attributes) + attrs := []attribute.KeyValue{ + semconvai.TraceloopWorkflowName.String(workflow.Attributes.Name), + semconvai.TraceloopSpanKind.String(string(semconvai.SpanKindAgent)), + semconvai.TraceloopEntityName.String(name), + } + + agentAssociationProps := make(map[string]string, len(associationProperties)+1) + maps.Copy(agentAssociationProps, associationProperties) + + if workflow.Attributes.ABTest != nil { + for key, activeVariant := range workflow.Attributes.ABTest.VariantKeys { + if activeVariant { + agentAssociationProps["ab_testing_variant"] = key + } + } + } + for key, value := range agentAssociationProps { + attrs = append(attrs, attribute.String("traceloop.association.properties."+key, value)) + } + + span.SetAttributes(attrs...) + + return &Agent{ + sdk: workflow.sdk, + workflow: workflow, + ctx: aCtx, + Attributes: AgentAttributes{ + Name: name, + AssociationProperties: agentAssociationProps, + }, + } }