Skip to main content

Overview

Custom interceptors allow you to modify RTP and RTCP packets in your WebRTC pipeline. By implementing the Interceptor interface, you can add custom logic for packet inspection, modification, statistics collection, or any other processing needs.

The Interceptor Interface

All interceptors must implement the Interceptor interface, which consists of seven methods:
type Interceptor interface {
    // BindRTCPReader lets you modify any incoming RTCP packets
    BindRTCPReader(reader RTCPReader) RTCPReader

    // BindRTCPWriter lets you modify any outgoing RTCP packets
    BindRTCPWriter(writer RTCPWriter) RTCPWriter

    // BindLocalStream lets you modify any outgoing RTP packets
    BindLocalStream(info *StreamInfo, writer RTPWriter) RTPWriter

    // UnbindLocalStream is called when the local stream is removed
    UnbindLocalStream(info *StreamInfo)

    // BindRemoteStream lets you modify any incoming RTP packets
    BindRemoteStream(info *StreamInfo, reader RTPReader) RTPReader

    // UnbindRemoteStream is called when the remote stream is removed
    UnbindRemoteStream(info *StreamInfo)

    // Close closes the Interceptor, cleaning up any data
    io.Closer
}

Starting with NoOp

The easiest way to create a custom interceptor is to embed the NoOp interceptor and override only the methods you need. The NoOp interceptor provides default implementations that pass packets through unchanged.
Embedding NoOp is the recommended approach - you only need to implement the specific methods relevant to your use case.

Basic NoOp Implementation

Here’s the complete NoOp interceptor from noop.go:1:
type NoOp struct{}

func (i *NoOp) BindRTCPReader(reader RTCPReader) RTCPReader {
    return reader
}

func (i *NoOp) BindRTCPWriter(writer RTCPWriter) RTCPWriter {
    return writer
}

func (i *NoOp) BindLocalStream(_ *StreamInfo, writer RTPWriter) RTPWriter {
    return writer
}

func (i *NoOp) UnbindLocalStream(_ *StreamInfo) {}

func (i *NoOp) BindRemoteStream(_ *StreamInfo, reader RTPReader) RTPReader {
    return reader
}

func (i *NoOp) UnbindRemoteStream(_ *StreamInfo) {}

func (i *NoOp) Close() error {
    return nil
}

Creating a Simple Interceptor

Let’s create a simple packet counter interceptor that tracks RTP packets:
package main

import (
    "sync/atomic"
    "github.com/pion/interceptor"
    "github.com/pion/rtp"
)

// PacketCounterInterceptor counts RTP packets
type PacketCounterInterceptor struct {
    interceptor.NoOp
    packetsReceived atomic.Uint64
    packetsSent     atomic.Uint64
}

// BindLocalStream intercepts outgoing RTP packets
func (p *PacketCounterInterceptor) BindLocalStream(
    info *interceptor.StreamInfo,
    writer interceptor.RTPWriter,
) interceptor.RTPWriter {
    return interceptor.RTPWriterFunc(
        func(header *rtp.Header, payload []byte, attributes interceptor.Attributes) (int, error) {
            p.packetsSent.Add(1)
            return writer.Write(header, payload, attributes)
        },
    )
}

// BindRemoteStream intercepts incoming RTP packets
func (p *PacketCounterInterceptor) BindRemoteStream(
    info *interceptor.StreamInfo,
    reader interceptor.RTPReader,
) interceptor.RTPReader {
    return interceptor.RTPReaderFunc(
        func(b []byte, a interceptor.Attributes) (int, interceptor.Attributes, error) {
            i, attr, err := reader.Read(b, a)
            if err == nil {
                p.packetsReceived.Add(1)
            }
            return i, attr, err
        },
    )
}

// GetStats returns packet statistics
func (p *PacketCounterInterceptor) GetStats() (sent, received uint64) {
    return p.packetsSent.Load(), p.packetsReceived.Load()
}

Working with StreamInfo

The StreamInfo parameter provides context about the stream:
type StreamInfo struct {
    ID                  string
    SSRC                uint32
    PayloadType         uint8
    MimeType            string
    ClockRate           uint32
    Channels            uint16
    RTPHeaderExtensions []RTPHeaderExtension
    RTCPFeedback        []RTCPFeedback
    // ... and more fields
}
Use StreamInfo to filter streams by SSRC, check for specific RTCP feedback types, or make decisions based on codec information.

Using Attributes

Attributes provide a way to pass metadata between interceptors:
// Store custom data in attributes
attr := make(interceptor.Attributes)
attr.Set("myKey", myValue)

// Retrieve RTP header from attributes (cached)
header, err := attr.GetRTPHeader(b)
if err != nil {
    return 0, nil, err
}

// Retrieve RTCP packets from attributes (cached)
pkts, err := attr.GetRTCPPackets(b)
if err != nil {
    return 0, nil, err
}
Attributes are shared between interceptors in the chain. The GetRTPHeader and GetRTCPPackets methods cache unmarshaled data to improve performance.

Interceptor with State Management

For more complex interceptors that maintain state per stream:
type StatefulInterceptor struct {
    interceptor.NoOp
    streams   map[uint32]*StreamState
    streamsMu sync.RWMutex
}

type StreamState struct {
    ssrc          uint32
    packetsCount  uint64
    lastTimestamp uint32
}

func (s *StatefulInterceptor) BindRemoteStream(
    info *interceptor.StreamInfo,
    reader interceptor.RTPReader,
) interceptor.RTPReader {
    // Initialize state for this stream
    s.streamsMu.Lock()
    s.streams[info.SSRC] = &StreamState{ssrc: info.SSRC}
    s.streamsMu.Unlock()

    return interceptor.RTPReaderFunc(
        func(b []byte, a interceptor.Attributes) (int, interceptor.Attributes, error) {
            i, attr, err := reader.Read(b, a)
            if err != nil {
                return 0, nil, err
            }

            if attr == nil {
                attr = make(interceptor.Attributes)
            }

            header, err := attr.GetRTPHeader(b[:i])
            if err != nil {
                return 0, nil, err
            }

            // Update state
            s.streamsMu.RLock()
            if state, ok := s.streams[header.SSRC]; ok {
                state.packetsCount++
                state.lastTimestamp = header.Timestamp
            }
            s.streamsMu.RUnlock()

            return i, attr, nil
        },
    )
}

// UnbindRemoteStream cleans up state when stream ends
func (s *StatefulInterceptor) UnbindRemoteStream(info *interceptor.StreamInfo) {
    s.streamsMu.Lock()
    delete(s.streams, info.SSRC)
    s.streamsMu.Unlock()
}
Always clean up resources in UnbindLocalStream, UnbindRemoteStream, and Close methods to prevent memory leaks.

Factory Pattern

Most interceptors use the Factory pattern for configuration:
type MyInterceptorFactory struct {
    opts []MyOption
}

type MyOption func(*MyInterceptor) error

// WithInterval sets the interval option
func WithInterval(interval time.Duration) MyOption {
    return func(i *MyInterceptor) error {
        i.interval = interval
        return nil
    }
}

// NewMyInterceptor creates a new factory
func NewMyInterceptor(opts ...MyOption) (*MyInterceptorFactory, error) {
    return &MyInterceptorFactory{opts: opts}, nil
}

// NewInterceptor implements interceptor.Factory
func (f *MyInterceptorFactory) NewInterceptor(id string) (interceptor.Interceptor, error) {
    i := &MyInterceptor{
        interval: 1 * time.Second, // default
    }

    // Apply options
    for _, opt := range f.opts {
        if err := opt(i); err != nil {
            return nil, err
        }
    }

    return i, nil
}

Complete Example: Packet Logger

Here’s a complete example that logs packet information:
package main

import (
    "log"
    "github.com/pion/interceptor"
    "github.com/pion/rtp"
)

type LoggingInterceptor struct {
    interceptor.NoOp
    prefix string
}

func NewLoggingInterceptor(prefix string) *LoggingInterceptor {
    return &LoggingInterceptor{prefix: prefix}
}

func (l *LoggingInterceptor) BindLocalStream(
    info *interceptor.StreamInfo,
    writer interceptor.RTPWriter,
) interceptor.RTPWriter {
    log.Printf("%s: Bound local stream SSRC=%d, PayloadType=%d",
        l.prefix, info.SSRC, info.PayloadType)

    return interceptor.RTPWriterFunc(
        func(header *rtp.Header, payload []byte, attributes interceptor.Attributes) (int, error) {
            log.Printf("%s: Sending RTP packet: SSRC=%d, Seq=%d, TS=%d, Size=%d",
                l.prefix, header.SSRC, header.SequenceNumber, header.Timestamp, len(payload))
            return writer.Write(header, payload, attributes)
        },
    )
}

func (l *LoggingInterceptor) UnbindLocalStream(info *interceptor.StreamInfo) {
    log.Printf("%s: Unbound local stream SSRC=%d", l.prefix, info.SSRC)
}

Next Steps

Examples

See real-world interceptor implementations

Best Practices

Learn patterns and practices for robust interceptors

Build docs developers (and LLMs) love