Skip to main content
k6’s extensibility allows you to create custom outputs that send test results to any destination or format. Custom outputs are built as Go extensions using the xk6 system.

Overview

Custom outputs enable you to:
  • Send metrics to proprietary systems
  • Implement custom data formats
  • Integrate with internal monitoring platforms
  • Add preprocessing or filtering logic
  • Create specialized storage solutions

Output Interface

All outputs must implement the Output interface (output/types.go:44):
type Output interface {
    Description() string
    Start() error
    AddMetricSamples(samples []metrics.SampleContainer)
    Stop() error
}

Method Descriptions

1
Description()
2
Returns a human-readable description shown in k6 run output:
3
func (o *MyOutput) Description() string {
    return "my-custom-output v1.0.0"
}
4
Start()
5
Called before the test begins. Initialize connections, start background goroutines:
6
func (o *MyOutput) Start() error {
    // Open connections, start flusher
    pf, err := output.NewPeriodicFlusher(1*time.Second, o.flushMetrics)
    if err != nil {
        return err
    }
    o.periodicFlusher = pf
    return nil
}
7
AddMetricSamples()
8
Receives metric samples. Must be non-blocking. Buffer samples for async processing:
9
func (o *MyOutput) AddMetricSamples(samples []metrics.SampleContainer) {
    // Buffer samples - don't do heavy processing here!
}
10
Stop()
11
Called when test ends. Flush remaining metrics and cleanup:
12
func (o *MyOutput) Stop() error {
    o.periodicFlusher.Stop()
    return o.client.Close()
}
AddMetricSamples() is called frequently and must not block. Use buffering and process metrics asynchronously to avoid impacting test performance.

Basic Example

Here’s a minimal custom output that counts metrics:
package myoutput

import (
    "go.k6.io/k6/metrics"
    "go.k6.io/k6/output"
)

func init() {
    output.RegisterExtension("myoutput", New)
}

type Output struct {
    output.SampleBuffer
    count int64
}

func New(params output.Params) (output.Output, error) {
    return &Output{}, nil
}

func (o *Output) Description() string {
    return "my-custom-output"
}

func (o *Output) Start() error {
    return nil
}

func (o *Output) Stop() error {
    // Process buffered samples
    samples := o.GetBufferedSamples()
    for _, sc := range samples {
        o.count += int64(len(sc.GetSamples()))
    }
    println("Total samples:", o.count)
    return nil
}

Registration

Register your output using RegisterExtension() (output/extensions.go:10):
func init() {
    output.RegisterExtension("myoutput", New)
}

func New(params output.Params) (output.Output, error) {
    return &Output{
        params: params,
        logger: params.Logger.WithFields(logrus.Fields{
            "output": "myoutput",
        }),
    }, nil
}
The registration must happen in an init() function so it’s automatically called when the extension is loaded.

Output Parameters

The Params struct (output/types.go:20) provides access to:
type Params struct {
    OutputType     string          // Output name
    ConfigArgument string          // CLI argument value
    JSONConfig     json.RawMessage // Configuration from script
    
    Logger         logrus.FieldLogger
    Environment    map[string]string
    StdOut         io.Writer
    StdErr         io.Writer
    FS             fsext.Fs
    ScriptPath     *url.URL
    ScriptOptions  lib.Options
    RuntimeOptions lib.RuntimeOptions
    ExecutionPlan  []lib.ExecutionStep
}

Usage Example

func New(params output.Params) (output.Output, error) {
    config := parseConfig(params.ConfigArgument, params.JSONConfig)
    
    logger := params.Logger.WithFields(logrus.Fields{
        "output": "myoutput",
        "config": params.ConfigArgument,
    })
    
    return &Output{
        config: config,
        logger: logger,
        env:    params.Environment,
    }, nil
}

Buffering Samples

Use SampleBuffer for efficient sample collection:
type Output struct {
    output.SampleBuffer  // Embed for buffering
    periodicFlusher *output.PeriodicFlusher
}

func (o *Output) Start() error {
    pf, err := output.NewPeriodicFlusher(1*time.Second, o.flushMetrics)
    if err != nil {
        return err
    }
    o.periodicFlusher = pf
    return nil
}

func (o *Output) flushMetrics() {
    samples := o.GetBufferedSamples()
    for _, sc := range samples {
        for _, sample := range sc.GetSamples() {
            // Process each sample
            o.processSample(sample)
        }
    }
}

func (o *Output) Stop() error {
    o.periodicFlusher.Stop()
    return nil
}
The PeriodicFlusher handles the complexity of periodic processing, batching, and graceful shutdown for you.

Working with Samples

Samples contain the metric data:
func (o *Output) processSample(sample metrics.Sample) {
    // Metric information
    metricName := sample.Metric.Name
    metricType := sample.Metric.Type
    
    // Sample data
    value := sample.Value
    timestamp := sample.Time
    
    // Tags
    tags := sample.Tags
    if status, ok := tags.Get("status"); ok {
        // Process status tag
    }
    
    // Send to your backend
    o.client.Send(metricName, value, timestamp, tags)
}

Configuration

Support configuration from multiple sources:
type Config struct {
    Endpoint string `json:"endpoint" envconfig:"MY_OUTPUT_ENDPOINT"`
    APIKey   string `json:"apiKey" envconfig:"MY_OUTPUT_API_KEY"`
}

func parseConfig(configArg string, jsonConfig json.RawMessage) Config {
    config := Config{
        Endpoint: "http://localhost:8080",  // defaults
    }
    
    // Parse JSON config from script
    if len(jsonConfig) > 0 {
        json.Unmarshal(jsonConfig, &config)
    }
    
    // Parse environment variables
    envconfig.Process("", &config)
    
    // CLI argument overrides
    if configArg != "" {
        config.Endpoint = configArg
    }
    
    return config
}
Users can then configure via:
# CLI
k6 run --out myoutput=http://localhost:9090 script.js

# Environment
export MY_OUTPUT_ENDPOINT=http://localhost:9090
k6 run --out myoutput script.js
// Script
export const options = {
  ext: {
    myoutput: {
      endpoint: 'http://localhost:9090',
      apiKey: 'secret',
    },
  },
};

Advanced Interfaces

Implement optional interfaces for additional functionality:

WithThresholds

Receive threshold configuration:
func (o *Output) SetThresholds(thresholds map[string]metrics.Thresholds) {
    o.thresholds = thresholds
}

WithStopWithTestError

Receive test error information on stop:
func (o *Output) StopWithTestError(testErr error) error {
    if testErr != nil {
        o.logger.WithError(testErr).Error("Test failed")
    }
    return o.flush()
}

WithTestRunStop

Stop the test programmatically:
func (o *Output) SetTestRunStopCallback(stopFn func(error)) {
    o.stopCallback = stopFn
}

// Later, stop the test if needed
if criticalError {
    o.stopCallback(errors.New("critical output failure"))
}

Real-World Example

Here’s a complete example from k6’s test suite (xk6-output-test/outputtest.go:1):
package outputtest

import (
    "io"
    "strconv"
    "go.k6.io/k6/metrics"
    "go.k6.io/k6/output"
)

func init() {
    output.RegisterExtension("outputtest", func(params output.Params) (output.Output, error) {
        return &Output{params: params}, nil
    })
}

type Output struct {
    params     output.Params
    calcResult float64
    outputFile io.WriteCloser
}

func (o *Output) Description() string {
    return "test output extension"
}

func (o *Output) Start() error {
    out, err := o.params.FS.Create(o.params.ConfigArgument)
    if err != nil {
        return err
    }
    o.outputFile = out
    return nil
}

func (o *Output) AddMetricSamples(sampleContainers []metrics.SampleContainer) {
    for _, sc := range sampleContainers {
        for _, sample := range sc.GetSamples() {
            if sample.Metric.Name == "foos" {
                o.calcResult += sample.Value
            }
        }
    }
}

func (o *Output) Stop() error {
    _, err := o.outputFile.Write([]byte(strconv.FormatFloat(o.calcResult, 'f', 0, 64)))
    if err != nil {
        return err
    }
    return o.outputFile.Close()
}

Building with xk6

Build your custom output:
xk6 build --with github.com/yourname/xk6-output-myoutput@latest
See Using Extensions for details.

Testing

Test your output:
func TestOutput(t *testing.T) {
    params := output.Params{
        Logger:         testutils.NewLogger(t),
        ConfigArgument: "test.out",
    }
    
    out, err := New(params)
    require.NoError(t, err)
    
    err = out.Start()
    require.NoError(t, err)
    
    // Send test samples
    out.AddMetricSamples([]metrics.SampleContainer{
        // ... test samples
    })
    
    err = out.Stop()
    require.NoError(t, err)
}
Always test your output with realistic workloads to ensure it doesn’t create performance bottlenecks.

Best Practices

1
Use Non-Blocking Operations
2
Buffer samples and process asynchronously to avoid slowing down tests.
3
Implement Graceful Shutdown
4
Flush all pending data in Stop() and handle interrupts cleanly.
5
Add Proper Error Handling
6
Log errors but don’t crash the test. Return errors from Start() and Stop().
7
Support Configuration
8
Accept configuration from CLI, environment variables, and script options.
9
Include Logging
10
Use the provided logger for debugging and operational visibility.
11
Handle Backpressure
12
If your backend is slow, implement backpressure to avoid memory issues.

Next Steps

Build docs developers (and LLMs) love