Overview

otelfile is a developer-experience (DX) focused OpenTelemetry span exporter for Go. It is engineered specifically for active development and manual endpoint testing.

Traditional tracing solutions force you to choose between running zero telemetry or hosting a complete Jaeger, Tempo, or OTel Collector database container. This introduces port constraints and system overhead that feel excessive during active developer loops.

The Concept: Just like logging to a file (e.g., app.log), otelfile dumps traces to a portable, single-file HTML document (trace.html). You can open this page directly in any modern browser to view full, interactive distributed tracing timelines.

Installation

Add otelfile to your Go module dependencies. The library is fully compliant with the standard go.opentelemetry.io/otel/sdk/trace core interfaces.

go get github.com/mukailasam/otelfile

Quick Start

Initialize the FileExporter and register it with your application's TracerProvider during setup.

package main

import (
    "context"
    "github.com/mukailasam/otelfile"
    "go.opentelemetry.io/otel"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
)

func main() {
    // Create the file-based exporter (Write to "trace.html", keep last 100 traces)
    exporter := otelfile.NewFileExporter("trace.html", 100)

    tp := sdktrace.NewTracerProvider(
        sdktrace.WithSimpleSpanProcessor(exporter),
    )
    otel.SetTracerProvider(tp)
    defer tp.Shutdown(context.Background())

    // Instrument as normal!
}

The Local Development Loop

Using otelfile is designed to feel visual and instantaneous. Once registered in your application, you can test endpoints as you write them:

1. Run your local application to initiate the export process:

go run main.go

2. Launch the HTML tracker file in your browser using standard operating system utilities:

# macOS
open trace.html

# Linux
xdg-open trace.html

# Windows
start trace.html

3. Trigger endpoints (using Postman, Chrome, or cURL):

curl http://localhost:8080/checkout

4. Refresh the browser tab. The trace list on the left will populate with your new requests. Select any item to view its nested, visual waterfall chart in real-time.

Configuration

The initialization function accepts two simple parameters:

NewFileExporter(filePath string, maxTraces int)

- filePath: The location on disk where the single-file HTML dashboard will be generated. Recommended: "trace.html".

- maxTraces: The rolling in-memory threshold. Keeps your HTML size small by automatically evicting historical spans when the threshold is crossed.

Basic Example

package main

import (
	"context"
	"fmt"
	"math/rand"
	"net/http"
	"time"

	"github.com/mukailasam/otelfile" // Your local package path
	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/propagation"
	"go.opentelemetry.io/otel/sdk/resource"
	sdktrace "go.opentelemetry.io/otel/sdk/trace"
	semconv "go.opentelemetry.io/otel/semconv/v1.17.0"
)

// initTracer initializes the OpenTelemetry pipeline using otelfile as the local exporter
func initTracer(serviceName string, filePath string) (*sdktrace.TracerProvider, func(context.Context) error, error) {
	if serviceName == "" {
		serviceName = "unknown_service"
	}

	// Create our file-based exporter (keeps a rolling log of 50 traces)
	exporter := otelfile.NewFileExporter(filePath, 0)

	// Build the resource matching semconv patterns and SchemaURL correctly
	res, err := resource.New(context.Background(),
		resource.WithSchemaURL(semconv.SchemaURL), // Correctly passes the schema URL
		resource.WithAttributes(
			semconv.ServiceName(serviceName),
		),
	)
	if err != nil {
		return nil, nil, err
	}

	// Register the exporter with the correct SimpleSpanProcessor setup
	tp := sdktrace.NewTracerProvider(
		sdktrace.WithSpanProcessor(sdktrace.NewSimpleSpanProcessor(exporter)), // Correct method signature
		sdktrace.WithResource(res),
	)

	// Set global tracer provider
	otel.SetTracerProvider(tp)

	// Establish Text Map Propagators (critical for context parsing across requests)
	otel.SetTextMapPropagator(
		propagation.NewCompositeTextMapPropagator(
			propagation.TraceContext{},
			propagation.Baggage{},
		),
	)

	// Return the provider and a safe shutdown function
	shutdownFunc := func(ctx context.Context) error {
		if tp != nil {
			return tp.Shutdown(ctx)
		}
		return nil
	}

	return tp, shutdownFunc, nil
}

func main() {
	// Initialize otelfile tracing pipeline on payment-api service
	_, shutdown, err := initTracer("payment-api", "trace.html")
	if err != nil {
		panic(err)
	}
	defer func() {
		_ = shutdown(context.Background())
	}()

	// Grab our tracer
	tracer := otel.Tracer("http-server")

	// Setup local web server for endpoint manual testing
	http.HandleFunc("/checkout", func(w http.ResponseWriter, r *http.Request) {
		// Extract incoming trace context from headers (like Postman / browser client headers)
		ctx := propagation.TraceContext{}.Extract(r.Context(), propagation.HeaderCarrier(r.Header))

		// Start the root span using the extracted context
		ctx, span := tracer.Start(ctx, "HTTP POST /checkout")
		defer span.End()

		span.SetAttributes(
			attribute.String("http.method", "POST"),
			attribute.String("checkout.cart_id", "cart_abc123"),
		)

		// Simulate Authorization child span
		func() {
			_, childSpan := tracer.Start(ctx, "AuthService.Authorize")
			defer childSpan.End()
			time.Sleep(time.Duration(30+rand.Intn(40)) * time.Millisecond)
		}()

		// Simulate Database child span
		func() {
			_, childSpan := tracer.Start(ctx, "DB SELECT cart_items")
			defer childSpan.End()
			childSpan.SetAttributes(
				attribute.String("db.system", "postgresql"),
				attribute.String("db.statement", "SELECT * FROM carts WHERE id = $1"),
			)
			time.Sleep(time.Duration(10+rand.Intn(50)) * time.Millisecond)
		}()

		// Simulate Payment gateway child span
		func() {
			_, childSpan := tracer.Start(ctx, "POST https://api.stripe.com/v3/charges")
			defer childSpan.End()
			time.Sleep(time.Duration(120+rand.Intn(100)) * time.Millisecond)
		}()

		w.Write([]byte("Order placed! Open trace.html in your browser."))
	})

	fmt.Println("Server running on http://localhost:8080")
	fmt.Println("Send requests to http://localhost:8080/checkout to generate trace.html")
	_ = http.ListenAndServe(":8080", nil)
}

Architecture

Standard tracing exporters send serialized protobuf or JSON spans over HTTP/gRPC networks to centralized servers.

otelfile intercepts spans during the batch flush. Rather than making outbound network connections, it formats the spans into a flattened, serialized JSON string. It then injects this payload directly into the bottom of a hardcoded, highly styled browser template, writing the combined page onto your local disk.

Eviction Strategy

Leaving local development apps running for long periods can result in infinite file sizes. To manage this safely:

- Spans are tracked and grouped internally by TraceID.

- When unique traces exceed your configured limit, the exporter purges the oldest stored trace IDs from memory.

- This prevents your disk file from bloating and ensures quick browser loading times.

FAQ

Is this suitable for production environments?

No. Writing synchronous I/O updates to disk and keeping a rolling trace cache in application RAM is an anti-pattern for large production applications. This tool is designed explicitly to run inside active local dev loops.

How do I share my trace?

Because the generated HTML file is completely self-contained and loads the database locally from embedded JSON blocks, you can simply share the `.html` file over Slack, email, or attach it to a pull request. The receiver can double-click it on their machine to view the exact waterfall chart.