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
Returns a human-readable description shown in k6 run output:
func (o *MyOutput) Description() string {
return "my-custom-output v1.0.0"
}
Called before the test begins. Initialize connections, start background goroutines:
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
}
Receives metric samples. Must be non-blocking. Buffer samples for async processing:
func (o *MyOutput) AddMetricSamples(samples []metrics.SampleContainer) {
// Buffer samples - don't do heavy processing here!
}
Called when test ends. Flush remaining metrics and cleanup:
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
Use Non-Blocking Operations
Buffer samples and process asynchronously to avoid slowing down tests.
Implement Graceful Shutdown
Flush all pending data in Stop() and handle interrupts cleanly.
Add Proper Error Handling
Log errors but don’t crash the test. Return errors from Start() and Stop().
Accept configuration from CLI, environment variables, and script options.
Use the provided logger for debugging and operational visibility.
If your backend is slow, implement backpressure to avoid memory issues.
Next Steps