Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
27 changes: 13 additions & 14 deletions .github/workflows/merge.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@

name: "Merge to main"
on:
pull_request:
branches: [ main ]
branches: [main]
jobs:
analyze:
name: Analyze
Expand All @@ -14,24 +13,24 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [ 'go' ]
language: ["go"]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
test:
name: Test
runs-on: ubuntu-latest
strategy:
matrix:
go-version: [ '1.23', '1.24' ]
go-version: ["1.24", "1.25"]
steps:
- name: Checkout repository
uses: actions/checkout@v4
Expand Down
126 changes: 125 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ This repository contains a generic HTTP client which can be adapted to provide:

* General HTTP methods for GET and POST of data
* Ability to send and receive JSON, plaintext and XML data
* Ability to send files and data of type `multipart/form-data`
* Ability to send files and data of type `multipart/form-data`
* Ability to send data of type `application/x-www-form-urlencoded`
* Debugging capabilities to see the request and response data
* Streaming text and JSON events
* OpenTelemetry tracing for distributed observability

API Documentation: <https://pkg.go.dev/github.com/mutablelogic/go-client>

Expand Down Expand Up @@ -65,6 +66,8 @@ Various options can be passed to the client `New` method to control its behaviou
overridden by the client for individual requests using `OptToken` (see below).
* `OptSkipVerify()` skips TLS certificate domain verification.
* `OptHeader(key, value string)` appends a custom header to each request.
* `OptTracer(tracer trace.Tracer)` sets an OpenTelemetry tracer for distributed tracing.
Span names default to "METHOD /path" format. See the OpenTelemetry section below for more details.

## Usage with a payload

Expand Down Expand Up @@ -283,3 +286,124 @@ is the same type as the object in the request.

You can return an error from the callback to stop the stream and return the error, or return `io.EOF` to stop the stream
immediately and return success.

## OpenTelemetry

The `pkg/otel` package provides OpenTelemetry tracing utilities for both HTTP clients and servers.

### Creating a Tracer Provider

Use `otel.NewProvider` to create a tracer provider that exports spans to an OTLP endpoint:

```go
package main

import (
"context"
"log"

"github.com/mutablelogic/go-client/pkg/otel"
)

func main() {
// Create a provider with an OTLP endpoint
// Supports http://, https://, grpc://, and grpcs:// schemes
provider, err := otel.NewProvider(
"https://otel-collector.example.com:4318", // OTLP endpoint
"api-key=your-api-key", // Optional headers (comma-separated key=value pairs)
"my-service", // Service name
otel.Attr{Key: "environment", Value: "production"}, // Optional attributes
)
if err != nil {
log.Fatal(err)
}
defer provider.Shutdown(context.Background())

// Get a tracer from the provider
tracer := provider.Tracer("my-service")

// Use the tracer with go-client
c, err := client.New(
client.OptEndpoint("https://api.example.com"),
client.OptTracer(tracer),
)
if err != nil {
log.Fatal(err)
}

// Use the client...
_ = c
}
```

### HTTP Client Tracing

When you set `OptTracer` on a client, all requests will automatically create spans with:

* HTTP method, URL, and host attributes
* Request and response body sizes
* HTTP status codes
* Error recording for failed requests

### HTTP Server Middleware

Use `otel.HTTPHandler` or `otel.HTTPHandlerFunc` to add tracing to your HTTP server:

```go
package main

import (
"context"
"log"
"net/http"

"github.com/mutablelogic/go-client/pkg/otel"
)

func main() {
// Create provider (see "Creating a Tracer Provider" above)
provider, err := otel.NewProvider("https://otel-collector.example.com:4318", "", "my-server")
if err != nil {
log.Fatal(err)
}
defer provider.Shutdown(context.Background())

tracer := provider.Tracer("my-server")

// Wrap your handler with the middleware
handler := otel.HTTPHandler(tracer)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello, World!"))
}))

// Or use HTTPHandlerFunc directly
handlerFunc := otel.HTTPHandlerFunc(tracer)(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello, World!"))
})

http.Handle("/", handler)
http.Handle("/func", handlerFunc)
http.ListenAndServe(":8080", nil)
}
```

The middleware automatically:

* Extracts trace context from incoming request headers (W3C Trace Context)
* Creates server spans with HTTP method, URL, and host attributes
* Captures response status codes
* Marks spans as errors for 4xx and 5xx responses

### Custom Spans

Use `otel.StartSpan` to create custom spans in your application:

```go
ctx, endSpan := otel.StartSpan(tracer, ctx, "MyOperation",
attribute.String("key", "value"),
)
// Use a closure to capture the final value of err when the function returns.
// defer endSpan(err) would capture err's value NOW (likely nil), not at return time.
defer func() { endSpan(err) }()

// Your code here...
```
30 changes: 17 additions & 13 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import (
"sync"
"time"

// Package imports
pkgotel "github.com/mutablelogic/go-client/pkg/otel"
trace "go.opentelemetry.io/otel/trace"

// Namespace imports
. "github.com/djthorpe/go-errors"
)
Expand Down Expand Up @@ -44,6 +48,7 @@ type Client struct {
token Token // token for authentication on requests
headers map[string]string // Headers for every request
ts time.Time
tracer trace.Tracer // Tracer used for requests
}

type ClientOpt func(*Client) error
Expand Down Expand Up @@ -105,7 +110,7 @@ func New(opts ...ClientOpt) (*Client, error) {
func (client *Client) String() string {
str := "<client"
if client.endpoint != nil {
str += fmt.Sprintf(" endpoint=%q", redactedUrl(client.endpoint))
str += fmt.Sprintf(" endpoint=%q", pkgotel.RedactedURL(client.endpoint))
}
if client.Client.Timeout > 0 {
str += fmt.Sprint(" timeout=", client.Client.Timeout)
Expand Down Expand Up @@ -160,7 +165,8 @@ func (client *Client) DoWithContext(ctx context.Context, in Payload, out any, op
opts = append([]RequestOpt{OptToken(client.token)}, opts...)
}

return do(client.Client, req, accept, client.strict, out, opts...)
// Do the request
return do(client.Client, req, accept, client.strict, client.tracer, out, opts...)
}

// Do a HTTP request and decode it into an object
Expand Down Expand Up @@ -188,7 +194,7 @@ func (client *Client) Request(req *http.Request, out any, opts ...RequestOpt) er
opts = append([]RequestOpt{OptToken(client.token)}, opts...)
}

return do(client.Client, req, "", false, out, opts...)
return do(client.Client, req, "", false, client.tracer, out, opts...)
}

// Debugf outputs debug information
Expand Down Expand Up @@ -251,7 +257,7 @@ func (client *Client) request(ctx context.Context, method, accept, mimetype stri
}

// Do will make a JSON request, populate an object with the response and return any errors
func do(client *http.Client, req *http.Request, accept string, strict bool, out any, opts ...RequestOpt) error {
func do(client *http.Client, req *http.Request, accept string, strict bool, tracer trace.Tracer, out any, opts ...RequestOpt) (err error) {
// Apply request options
reqopts := requestOpts{
Request: req,
Expand All @@ -270,8 +276,13 @@ func do(client *http.Client, req *http.Request, accept string, strict bool, out
client.Timeout = 0
}

// Create span if tracer provided
var response *http.Response
req, finishSpan := pkgotel.StartHTTPClientSpan(tracer, req)
defer func() { finishSpan(response, err) }()

// Do the request
response, err := client.Do(req)
response, err = client.Do(req)
if err != nil {
return err
}
Expand All @@ -296,7 +307,7 @@ func do(client *http.Client, req *http.Request, accept string, strict bool, out
// When in strict mode, check content type returned is as expected
if strict && (accept != "" && accept != ContentTypeAny) {
if mimetype != accept {
return ErrUnexpectedResponse.Withf("strict mode: unexpected responsse with %q", mimetype)
return ErrUnexpectedResponse.Withf("strict mode: unexpected response with %q", mimetype)
}
}

Expand Down Expand Up @@ -359,10 +370,3 @@ func respContentType(resp *http.Response) (string, error) {
return mimetype, nil
}
}

// Remove any usernames and passwords before printing out
func redactedUrl(url *url.URL) string {
url_ := *url // make a copy
url_.User = nil
return url_.String()
}
12 changes: 12 additions & 0 deletions clientopts.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import (
"strings"
"time"

// Package imports
"go.opentelemetry.io/otel/trace"

// Namespace imports
. "github.com/djthorpe/go-errors"
)
Expand Down Expand Up @@ -92,6 +95,15 @@ func OptReqToken(value Token) ClientOpt {
}
}

// OptTracer sets the open-telemetry tracer for any request made by this client.
// Span names default to "METHOD /path" format.
func OptTracer(tracer trace.Tracer) ClientOpt {
return func(client *Client) error {
client.tracer = tracer
return nil
}
}

// OptSkipVerify skips TLS certificate domain verification
func OptSkipVerify() ClientOpt {
return func(client *Client) error {
Expand Down
Loading
Loading