Writing Go Extensions
This guide walks you through creating a Go extension for Built On Envoy. Go extensions are HTTP filters that run inside Envoy and can inspect and modify requests and responses as they flow through the proxy.
Prerequisites
Refer to the Running Local Extensions page for the details about the build prerequisites.
Quick Start
Create a new extension called my-extension in the my-extension directory with a single command:
boe create my-extension --path my-extension
This generates a ready-to-use extension with all required files. You can build and test it with:
boe run --local my-extension/
Test with curl:
curl -v http://localhost:10000/status/200
You should see your custom header x-my-extension: example in the response.
Extension Structure
The Filter Implementation
A Go extension implements uses the Go SDK for the Envoy Dynamic Modules. The following example shows a minimal plugin and the main components that are needed:
package main
import (
"github.com/envoyproxy/envoy/source/extensions/dynamic_modules/sdk/go/shared"
)
// The HTTP filter that processes each request/response
type customHttpFilter struct {
shared.EmptyHttpFilter
handle shared.HttpFilterHandle
}
// Called when request headers arrive
func (f *customHttpFilter) OnRequestHeaders(headers shared.HeaderMap, endStream bool) shared.HeadersStatus {
headers.Set("x-custom-header", "value")
f.handle.Log(shared.LogLevelInfo, "Processing request")
return shared.HeadersStatusContinue
}
// Called when response headers arrive
func (f *customHttpFilter) OnResponseHeaders(headers shared.HeaderMap, endStream bool) shared.HeadersStatus {
return shared.HeadersStatusContinue
}
// Factory that creates filter instances
type customHttpFilterFactory struct{}
func (f *customHttpFilterFactory) Create(handle shared.HttpFilterHandle) shared.HttpFilter {
return &customHttpFilter{handle: handle}
}
// Config factory - entry point for the extension
type customHttpFilterConfigFactory struct {
shared.EmptyHttpFilterConfigFactory
}
func (f *customHttpFilterConfigFactory) Create(handle shared.HttpFilterConfigHandle, config []byte) (shared.HttpFilterFactory, error) {
return &customHttpFilterFactory{}, nil
}
// Required entry point - maps extension name to its config factory
func WellKnownHttpFilterConfigFactories() map[string]shared.HttpFilterConfigFactory {
return map[string]shared.HttpFilterConfigFactory{
"my-extension": &customHttpFilterConfigFactory{},
}
}
Key components:
| Component | Purpose |
|---|---|
customHttpFilter | Processes individual HTTP streams |
customHttpFilterFactory | Creates filter instances for each stream |
customHttpFilterConfigFactory | Parses configuration, creates factories |
WellKnownHttpFilterConfigFactories() | Entry point that registers the extension |
The Manifest
The manifest describes your extension:
name: my-extension
version: 0.0.1
type: go
composerVersion: 0.2.2
categories:
- Security # Or: Traffic, Observability, Examples, Misc
author: Your Name
description: Short description of what your extension does.
longDescription: |
A longer description with details about features,
use cases, and configuration options.
tags:
- go
- http
- filter
license: Apache-2.0
examples:
- title: Basic usage
description: Run the extension
code: |
boe run --extension my-extension
Important: The composerVersion field must match the version of the Composer dynamic module that will load your plugin.
See the version compatibility section for more details.
Filter Lifecycle
The filter receives callbacks at each stage of request/response processing:
flowchart LR
subgraph "Request Flow"
direction LR
OnRequestHeaders --> OnRequestBody --> OnRequestTrailers
end
flowchart LR
subgraph "Response Flow"
direction LR
OnResponseHeaders --> OnResponseBody --> OnResponseTrailers
end
Callback Return Values
Each callback returns a status that controls processing. The following table shows the most commonly used return values. For more details about the different return values, refer to the Dynamic Modules Go SDK.
| Return Value | Description |
|---|---|
HeadersStatusContinue | Continue to the next filter in the chain |
HeadersStatusStop | Stop processing headers and continue processing the request (body, trailers, etc) |
BodyStatusContinue | Continue to the next filter in the chain |
BodyStatusStopAndBuffer | Stop processing the body and buffer the data |
TrailersStatusContinue | Continue to the next filter in the chain |
Common Operations
Working with Headers
func (f *customHttpFilter) OnRequestHeaders(headers shared.HeaderMap, endStream bool) shared.HeadersStatus {
// Get a header
host := headers.GetOne("host")
// Get all headers
allHeaders := headers.GetAll()
// Set a header (overwrites if exists)
headers.Set("x-custom", "value")
// Remove a header
headers.Remove("x-unwanted")
return shared.HeadersStatusContinue
}
Modifying the Request Body
To modify the body, you need to buffer it first by returning HeadersStatusStop from OnRequestHeaders:
func (f *customHttpFilter) OnRequestHeaders(headers shared.HeaderMap, endStream bool) shared.HeadersStatus {
if !endStream {
return shared.HeadersStatusStop // Wait for body
}
return shared.HeadersStatusContinue
}
func (f *customHttpFilter) OnRequestBody(body shared.BodyBuffer, endStream bool) shared.BodyStatus {
if !endStream {
return shared.BodyStatusStopAndBuffer // Keep buffering
}
// Get the complete buffered body
bufferedBody := f.handle.BufferedRequestBody()
// Read original content
var original []byte
for _, chunk := range bufferedBody.GetChunks() {
original = append(original, chunk...)
}
// Replace with new content
bufferedBody.Drain(bufferedBody.GetSize())
bufferedBody.Append([]byte("Modified body"))
// Update content-length header
f.handle.RequestHeaders().Remove("content-length")
return shared.BodyStatusContinue
}
Sending a Local Response
You can short-circuit the request and return a response directly:
func (f *customHttpFilter) OnRequestHeaders(headers shared.HeaderMap, endStream bool) shared.HeadersStatus {
if headers.GetOne("x-block") == "true" {
f.handle.SendLocalResponse(
403, // Status code
nil, // Headers (optional)
[]byte("Access denied"), // Body
"my-extension", // Detail
)
return shared.HeadersStatusStop
}
return shared.HeadersStatusContinue
}
Accessing Request Attributes
func (f *customHttpFilter) OnRequestHeaders(headers shared.HeaderMap, endStream bool) shared.HeadersStatus {
host, _ := f.handle.GetAttributeString(shared.AttributeIDRequestHost)
f.handle.Log(shared.LogLevelInfo, "Request host: %s", host)
return shared.HeadersStatusContinue
}
Working with Metadata
Store and retrieve dynamic metadata:
// Set metadata
f.handle.SetMetadata("my-namespace", "key", "value")
f.handle.SetMetadata("my-namespace", "count", int64(42))
// Get metadata
strValue, _ := f.handle.GetMetadataString(shared.MetadataSourceTypeDynamic, "my-namespace", "key")
numValue, _ := f.handle.GetMetadataNumber(shared.MetadataSourceTypeDynamic, "my-namespace", "count")
Logging
f.handle.Log(shared.LogLevelDebug, "Debug message: %s", value)
f.handle.Log(shared.LogLevelInfo, "Info message")
f.handle.Log(shared.LogLevelWarn, "Warning: %d items", count)
f.handle.Log(shared.LogLevelError, "Error: %v", err)
Defining Metrics
Define metrics in the config factory, use them in the filter:
type customHttpFilterFactory struct {
counter shared.MetricID
hasCounter bool
}
func (f *customHttpFilterConfigFactory) Create(handle shared.HttpFilterConfigHandle, config []byte) (shared.HttpFilterFactory, error) {
factory := &customHttpFilterFactory{}
// Define a counter
counter, status := handle.DefineCounter("my_extension_requests")
if status == shared.MetricsSuccess {
factory.counter = counter
factory.hasCounter = true
}
// Define a counter with tags
taggedCounter, _ := handle.DefineCounter("my_extension_requests_by_path", "path")
// Define a gauge
gauge, _ := handle.DefineGauge("my_extension_active")
// Define a histogram
histogram, _ := handle.DefineHistogram("my_extension_latency")
return factory, nil
}
// In the filter:
func (f *customHttpFilter) OnRequestHeaders(headers shared.HeaderMap, endStream bool) shared.HeadersStatus {
if f.factory.hasCounter {
f.handle.IncrementCounterValue(f.factory.counter, 1)
}
return shared.HeadersStatusContinue
}
Building and Testing
Build Commands
# Build the plugin
make build
# Install to local boe data directory
make install
# Clean build artifacts
make clean
Running Locally
During development, run your extension directly from the source directory:
boe run --local my-extension/
This is faster than installing because it doesn’t require copying files.
Viewing Logs
Enable debug logging to see your filter’s log messages:
boe run --local my-extension/ --log-level all:debug
Or for just your extension’s logs:
boe run --local my-extension/ --log-level dynamic_modules:debug
Testing with curl
By default, boe run will start Envoy and proxy https://httpbin.org, so you can use curl to send any request
and verify the headers, return codes, etc.
# Basic request
curl http://localhost:10000/status/200
# With verbose output to see headers
curl -v http://localhost:10000/status/200
# POST with body
curl -X POST -d '{"test": "data"}' http://localhost:10000/status/200
# With custom header
curl -H "x-custom: value" http://localhost:10000/status/200
Version Compatibility
Go plugins have strict version requirements. See the Extension types page for further details about version compatibility and how the internals work.
The composerVersion in your manifest indicates which Composer version your plugin is compatible with.
When the Composer loads your plugin, it validates these requirements.
Complete Example
For a complete example, refer to the example-go extension in GitHub.
Next Steps
- Browse the Extensions Catalog for more examples
- Check the Extension Manifest Reference for all manifest options
- See the CLI Commands for more runtime options