trace

package
v2.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 20, 2026 License: MIT Imports: 15 Imported by: 0

README

Trace Package

The trace package provides a thin, ergonomic wrapper around OpenTelemetry (OTEL) distributed tracing for GitLab Go services.

Engineers in GitLab must use this package to instrument their services so that trace data is emitted consistently across all systems.

Quick Start

tracer, shutdown, err := trace.New(ctx)
if err != nil {
    log.Fatal(err)
}
defer shutdown(ctx)

ctx, span := tracer.Start(ctx, "my-operation")
defer span.End()

span.SetAttributes(attribute.String("user.id", userID))

New exports spans via OTLP/HTTP to localhost:4318 by default. The collector endpoint and other settings can be configured without code changes via the GITLAB_TRACING environment variable (see below) or via Config.

Environment Variables

The recommended way to configure tracing in cloud-native deployments is via the GITLAB_TRACING environment variable. It accepts a connection string that controls the collector endpoint, TLS, service name, and sampling in a single value.

Connection string format
<scheme>://<host>:<port>[?param=value&...]
Scheme Transport TLS
otlp HTTP No (insecure)
otlps HTTP Yes
otlp+tls HTTP Yes
Query parameter Example Description
service_name my-api Sets the service.name resource attribute
sampler probabilistic Sampler type
sampler_param 0.1 to sample 10%; 0 to drop all; 1 to keep all Sampler parameter (float in [0.0, 1.0])
Examples
# Insecure local collector, 10% sampling
GITLAB_TRACING="otlp://localhost:4318?sampler=probabilistic&sampler_param=0.1"

# TLS-enabled remote collector, always sample, custom service name
GITLAB_TRACING="otlps://collector.example.com:4317?service_name=gitaly&sampler=probabilistic&sampler_param=1"

In Kubernetes:

env:
  - name: GITLAB_TRACING
    value: "otlps://otel-collector.observability:4317?service_name=my-service&sampler=probabilistic&sampler_param=0.05"
Precedence

Values set in Config take precedence over GITLAB_TRACING. The env var fills in only fields that are left at their zero value in the Config struct, so you can mix both: use GITLAB_TRACING for the endpoint and set SampleRate programmatically, for example.

Programmatic Configuration

tracer, shutdown, err := trace.NewWithConfig(ctx, &trace.Config{
    // ServiceName is included as the service.name resource attribute on every span.
    // Defaults to the binary name when empty.
    ServiceName: "gitaly",

    // ServiceVersion is included as the service.version resource attribute.
    ServiceVersion: "16.0.0",

    // Endpoint is the base URL of the OTLP HTTP collector (no path suffix).
    Endpoint: "https://collector.example.com:4318",

    // Insecure disables TLS. Useful for local development.
    Insecure: true,

    // Headers are sent with every export request, e.g. for authentication.
    Headers: map[string]string{"x-honeycomb-team": token},

    // SampleRate controls the fraction of traces that are sampled [0.0, 1.0].
    // An explicit 0 drops all spans. Defaults to 1% only when Config is nil.
    SampleRate: 0.1,
})

To explicitly drop all spans (useful in tests that use the real provider):

tracer, shutdown, err := trace.NewWithConfig(ctx, &trace.Config{
    SampleRate: trace.SampleRateDropAll,
})

Parsing a Tracing URL from Any Source

Some services receive a tracing connection string from an external source (e.g. a Rails JSON response or a CLI flag) rather than from GITLAB_TRACING. Use ParseTracingConfig to parse it, then use the fields directly with the OTEL SDK:

cfg := trace.ParseTracingConfig(urlFromRails)
if cfg == nil {
    // URL was empty or unparseable — proceed without tracing.
    return
}

// Build exporter options directly from the parsed fields.
var exporterOpts []otlptracehttp.Option
if cfg.Endpoint != "" {
    exporterOpts = append(exporterOpts, otlptracehttp.WithEndpointURL(cfg.Endpoint+cfg.URLPath))
}
if cfg.Insecure {
    exporterOpts = append(exporterOpts, otlptracehttp.WithInsecure())
}

exporter, err := otlptracehttp.New(ctx, exporterOpts...)
if err != nil { ... }

provider := sdktrace.NewTracerProvider(
    sdktrace.WithBatcher(exporter),
    sdktrace.WithSampler(cfg.Sampler()),
)
defer provider.Shutdown(ctx)

// Wrap the provider for the labkit Tracer API, or use it directly.
tracer := trace.NewWithProvider(provider)

To read from the environment without going through New:

cfg := trace.ParseTracingConfigFromEnv()
if cfg == nil {
    // GITLAB_TRACING is unset or unparseable.
    return
}
// cfg.Endpoint, cfg.URLPath, cfg.Insecure, cfg.ServiceName, cfg.SampleRate are available.
TracingConfig fields
Field Type Description
Endpoint string Base URL of the OTLP collector (scheme + host + port). Empty when no host was present in the URL (in which case ParseTracingConfig returns nil).
URLPath string Path component of the connection string, e.g. /otel/v1/traces. Empty when no path was present. OTLPHTTPOptions appends this to Endpoint automatically.
Insecure bool True when the scheme was otlp (no TLS)
ServiceName string Value of the service_name query parameter
SampleRate float64 Parsed sample rate in [0.0, 1.0]. Only meaningful when SamplerSet is true; zero otherwise.
SamplerSet bool True when a sampler and a valid sampler_param were both present. For probabilistic, the param must be in [0.0, 1.0].

Sampler() uses SampleRate only when SamplerSet is true. When SamplerSet is false it returns sdktrace.ParentBased(sdktrace.AlwaysSample()), which is the OpenTelemetry SDK default (sample all root spans, follow parent decision for child spans).

Creating Spans and Using the OTel API

Tracer.Start returns the raw oteltrace.Span interface — the full OTel span API (attributes, events, links, context propagation, etc.) is available without any labkit wrapper. See the OpenTelemetry Go instrumentation guide and the go.opentelemetry.io/otel/trace package reference.

Recording errors

trace.RecordError records the exception event and sets the span status to codes.Error in one call. The raw OTel Span.RecordError only records the event; setting the status requires a separate span.SetStatus call.

result, err := doWork(ctx)
if err != nil {
    trace.RecordError(span, err)
    return err
}
Accessing underlying OTel types

To pass the tracer to a library that requires oteltrace.Tracer directly:

oteltracer := tracer.OTELTracer()
thirdPartyLib.Init(oteltracer)

To retrieve the underlying provider:

provider := tracer.Provider()
thirdPartyLib.SetProvider(provider)

Testing

Use tracetest.NewRecorder to capture completed spans in memory without a live collector. The recorder always samples and does not register a global provider, keeping tests isolated.

import "gitlab.com/gitlab-org/labkit/v2/testing/tracetest"

func TestMyHandler(t *testing.T) {
    tracer, rec := tracetest.NewRecorder()

    ctx, span := tracer.Start(context.Background(), "handle-request")
    span.SetAttributes(attribute.Int("user.id", 42))
    trace.RecordError(span, errors.New("something went wrong"))
    span.End()

    spans := rec.Ended()
    require.Len(t, spans, 1)

    s := spans[0]
    assert.Equal(t, "handle-request", s.Name)
    assert.Equal(t, int64(42), s.Attributes["user.id"])
    assert.True(t, s.HasError)
    assert.Equal(t, codes.Error, s.StatusCode)
    assert.Equal(t, "something went wrong", s.StatusMsg)
}

rec.Reset() clears recorded spans between sub-tests when a single recorder is shared across a table-driven test.

Documentation

Overview

Package trace provides a thin, ergonomic wrapper around OpenTelemetry distributed tracing for GitLab Go services.

The package exposes four groups of API:

Quick start — managed setup

For most services, New wires everything up from the GitLabTracingEnvVar environment variable and sensible defaults:

tracer, shutdown, err := trace.New(ctx)
if err != nil {
	log.Fatal(err)
}
defer shutdown(ctx)

ctx, span := tracer.Start(ctx, "my-operation")
defer span.End()

span.SetAttributes(attribute.String("user.id", userID))

Error recording

result, err := doWork(ctx)
if err != nil {
	trace.RecordError(span, err) // records exception event AND sets status=Error
	return err
}

Propagating context downstream

The context returned by Tracer.Start carries the active span. Pass it to downstream calls so that child spans are nested correctly:

ctx, span := tracer.Start(ctx, "parent")
defer span.End()

ctx, child := tracer.Start(ctx, "child")
defer child.End()

Advanced setup — build your own provider

When you receive a tracing URL from a source other than the environment (e.g. a JSON configuration document from Rails), parse it with ParseTracingConfig and use the fields directly with the OTEL SDK:

cfg := trace.ParseTracingConfig(urlFromRails)
if cfg == nil {
	// URL was empty or unparseable — proceed without tracing.
}

var exporterOpts []otlptracehttp.Option
if cfg.Endpoint != "" {
	exporterOpts = append(exporterOpts, otlptracehttp.WithEndpointURL(cfg.Endpoint+cfg.URLPath))
}
if cfg.Insecure {
	exporterOpts = append(exporterOpts, otlptracehttp.WithInsecure())
}

exporter, err := otlptracehttp.New(ctx, exporterOpts...)
if err != nil { ... }

provider := sdktrace.NewTracerProvider(
	sdktrace.WithBatcher(exporter),
	sdktrace.WithSampler(cfg.Sampler()),
)
defer provider.Shutdown(ctx)

// Wrap the provider in a LabKit Tracer for convenience, or use it directly.
tracer := trace.NewWithProvider(provider)

Using the full OpenTelemetry API

Tracer.Start returns go.opentelemetry.io/otel/trace.Span directly, so the full OTEL span API is available without any labkit wrapper:

span.AddEvent("cache-miss", oteltrace.WithAttributes(attribute.String("key", k)))
span.AddLink(oteltrace.Link{SpanContext: remoteCtx})
sc := span.SpanContext() // retrieve trace-id / span-id

To pass the tracer to a third-party OTEL-instrumented library that requires go.opentelemetry.io/otel/trace.Tracer (a sealed interface), use Tracer.OTELTracer:

oteltracer := tracer.OTELTracer()
thirdPartyLib.Init(oteltracer)

Tracer.Provider returns the underlying sdktrace.TracerProvider for passing to instrumentation libraries that require it:

provider := tracer.Provider()
thirdPartyLib.SetProvider(provider)

Environment-based configuration (GITLAB_TRACING)

The GitLabTracingEnvVar environment variable configures the tracer without changing code. It uses a URL-style connection string:

GITLAB_TRACING="otlp://collector.example.com:4318?service_name=my-api&sampler=probabilistic&sampler_param=0.01"

Scheme:

  • otlp — HTTP (insecure)
  • otlps — HTTPS (secure)

Query parameters:

  • service_name — overrides [Config.ServiceName]
  • sampler — "probabilistic" or "const"
  • sampler_param — rate (0.0–1.0) for probabilistic; 0 or 1 for const

When a non-nil Config is also passed to NewWithConfig, explicit Config fields take priority: Endpoint and ServiceName fill in only if they are empty; SampleRate in the Config is always used as-is.

Testing

Use NewWithProvider together with the tracetest package to capture spans without a live collector:

import "gitlab.com/gitlab-org/labkit/v2/testing/tracetest"

tracer, rec := tracetest.NewRecorder()
ctx, span := tracer.Start(context.Background(), "op")
span.End()

spans := rec.Ended()
assert.Equal(t, "op", spans[0].Name)
Example

Example shows managed setup using New, which reads GITLAB_TRACING and applies sensible defaults. This is the right starting point for most services.

package main

import (
	"context"
	"log"

	"gitlab.com/gitlab-org/labkit/v2/trace"
	"go.opentelemetry.io/otel/attribute"
)

// Package-level attribute.Key constants give each attribute a stable, typed
// name. Call the key's method (String, Int64, Bool, …) to produce a KeyValue —
// mismatched key strings across call sites become impossible.
const (
	attrUserID attribute.Key = "user.id"
	attrMRIID  attribute.Key = "mr.iid"
	attrDryRun attribute.Key = "dry_run"
)

func main() {
	ctx := context.Background()

	tracer, shutdown, err := trace.New(ctx)
	if err != nil {
		log.Fatal(err)
	}
	defer shutdown(ctx) //nolint:errcheck

	_, span := tracer.Start(ctx, "my-operation")
	defer span.End()

	span.SetAttributes(
		attrUserID.String("u-42"),
		attrMRIID.Int64(101),
		attrDryRun.Bool(false),
	)
}

Index

Examples

Constants

View Source
const GitLabTracingEnvVar = "GITLAB_TRACING"

GitLabTracingEnvVar is the name of the environment variable that holds the GitLab tracing connection string. It is exported so that callers can read or watch the variable themselves.

View Source
const SampleRateDropAll float64 = -1

SampleRateDropAll may be assigned to [Config.SampleRate] to explicitly configure a TracerProvider that never samples spans. Setting SampleRate to 0 has the same effect.

Variables

This section is empty.

Functions

func RecordError

func RecordError(span oteltrace.Span, err error)

RecordError records err as an exception span event and sets the span status to codes.Error with err.Error() as the description. It is a no-op when err is nil.

Note: the raw OTEL oteltrace.Span.RecordError records the exception event but does not set the span status. This function does both, which is almost always what you want.

Example

ExampleRecordError shows how to record an error on a span. Unlike the raw OTEL Span.RecordError, trace.RecordError also sets the span status to codes.Error in the same call.

package main

import (
	"context"
	"errors"
	"log"

	"gitlab.com/gitlab-org/labkit/v2/trace"
)

func main() {
	ctx := context.Background()

	tracer, shutdown, err := trace.New(ctx)
	if err != nil {
		log.Fatal(err)
	}
	defer shutdown(ctx) //nolint:errcheck

	_, span := tracer.Start(ctx, "do-work")
	defer span.End()

	if err := errors.New("something failed"); err != nil {
		trace.RecordError(span, err)
	}
}

func SetAttributes

func SetAttributes(span oteltrace.Span, attrs ...attribute.KeyValue)

SetAttributes sets attrs on span. It is a no-op when span is nil, making it safe to call regardless of whether tracing is configured.

Types

type Config

type Config struct {
	// ServiceName is the service.name resource attribute included on all spans.
	// Default: path.Base(os.Args[0])
	ServiceName string

	// ServiceVersion is the service.version resource attribute.
	// Omitted from the resource when empty.
	ServiceVersion string

	// Endpoint is the full URL of the OTLP HTTP collector, including any path.
	// Example: "https://collector.example.com:4318/otel/v1/traces"
	// Default: "" — the SDK honours OTEL_EXPORTER_OTLP_ENDPOINT or falls back
	// to http://localhost:4318.
	Endpoint string

	// Insecure disables TLS when connecting to the OTLP collector.
	// Useful for local development. Only applied when Endpoint is also set.
	Insecure bool

	// Headers are additional HTTP headers sent with every export request,
	// for example authentication tokens required by managed collectors.
	Headers map[string]string

	// SampleRate controls the fraction of traces that are sampled, in the
	// range [0.0, 1.0]. 0 disables sampling entirely. Values >= 1.0 sample
	// every trace. [SampleRateDropAll] (-1) is an alias for 0 kept for
	// clarity. Default when Config is nil: 0.01 (1%).
	SampleRate float64
}

Config holds the configuration for creating a new Tracer. A nil Config passed to NewWithConfig produces sensible defaults: 1% sampling, OTLP/HTTP export to localhost:4318, and the service name derived from the process binary.

type Tracer

type Tracer struct {
	// contains filtered or unexported fields
}

Tracer creates spans for tracing operations. Obtain one from New or NewWithConfig, then call Tracer.Start to begin each operation you want to trace.

func New

func New(ctx context.Context) (*Tracer, func(context.Context) error, error)

New returns a Tracer and a shutdown function using default Config. The shutdown function must be called on application exit to flush any buffered spans before the process terminates.

tracer, shutdown, err := trace.New(ctx)
if err != nil { ... }
defer shutdown(ctx)

func NewWithConfig

func NewWithConfig(ctx context.Context, cfg *Config, opts ...sdktrace.TracerProviderOption) (*Tracer, func(context.Context) error, error)

NewWithConfig returns a Tracer and a shutdown function using the provided Config. A nil cfg causes all values to be derived from the [GITLAB_TRACING] environment variable (when set) or built-in defaults.

When cfg is non-nil, [GITLAB_TRACING] fills in any fields that are left at their zero value (empty string for Endpoint / ServiceName). SampleRate in the explicit Config always takes precedence; set it to the SampleRateDropAll sentinel if you want to disable sampling regardless of the environment.

Additional sdktrace.TracerProviderOption values may be passed as opts and are appended to the provider after the defaults derived from cfg. Use this to inject extra span processors, additional exporters, or any other provider option not covered by Config without requiring labkit changes:

tracer, shutdown, err := trace.NewWithConfig(ctx, cfg,
    sdktrace.WithSpanProcessor(myProcessor),
)
Example (WithProviderOptions)

ExampleNewWithConfig_withProviderOptions shows how to inject additional sdktrace.TracerProviderOption values via the variadic opts parameter. Use this for a second exporter, custom batch settings, or any other provider option not covered by trace.Config — without requiring labkit changes.

package main

import (
	"context"
	"log"

	"gitlab.com/gitlab-org/labkit/v2/trace"

	sdktrace "go.opentelemetry.io/otel/sdk/trace"
)

func main() {
	ctx := context.Background()

	// myProcessor could be any sdktrace.SpanProcessor implementation.
	var myProcessor sdktrace.SpanProcessor

	tracer, shutdown, err := trace.NewWithConfig(ctx,
		&trace.Config{SampleRate: trace.SampleRateDropAll},
		sdktrace.WithSpanProcessor(myProcessor),
	)
	if err != nil {
		log.Fatal(err)
	}
	defer shutdown(ctx) //nolint:errcheck
	_ = tracer
}

func NewWithProvider

func NewWithProvider(provider *sdktrace.TracerProvider) *Tracer

NewWithProvider wraps an existing sdktrace.TracerProvider in a Tracer. It is intended for testing (via the tracetest package) and advanced use cases where the caller manages the provider lifecycle directly.

func (*Tracer) OTELTracer

func (t *Tracer) OTELTracer() oteltrace.Tracer

OTELTracer returns the underlying oteltrace.Tracer. Use this when passing a tracer to a third-party OTEL-instrumented library that requires the raw interface. Note: oteltrace.Tracer is a sealed interface; *Tracer cannot implement it directly.

func (*Tracer) Provider

func (t *Tracer) Provider() *sdktrace.TracerProvider

Provider returns the underlying sdktrace.TracerProvider. Use this when code outside of LabKit needs the provider directly, for example to register it as a global or to pass it to another instrumentation library.

Example

ExampleTracer_Provider shows how to retrieve the underlying TracerProvider from a LabKit Tracer, for example to pass it to an instrumentation library that requires it directly.

package main

import (
	"context"
	"log"

	"gitlab.com/gitlab-org/labkit/v2/trace"
)

func main() {
	ctx := context.Background()

	tracer, shutdown, err := trace.NewWithConfig(ctx, &trace.Config{
		SampleRate: trace.SampleRateDropAll,
	})
	if err != nil {
		log.Fatal(err)
	}
	defer shutdown(ctx) //nolint:errcheck

	provider := tracer.Provider()
	_ = provider // pass to an instrumentation library that requires it
}

func (*Tracer) Start

Start begins a new span named name, forwarding any span start options to the underlying OTEL tracer. The returned context carries the active span and must be passed to downstream calls. Always pair Start with a deferred End:

ctx, span := tracer.Start(ctx, "do-work")
defer span.End()

type TracingConfig

type TracingConfig struct {
	// Endpoint is the base URL of the OTLP HTTP collector (scheme + host),
	// e.g. "http://host:4318" or "https://host:4317". The path component, if
	// any, is stored separately in URLPath.
	Endpoint string

	// URLPath is the path component of the connection string, e.g.
	// "/otel/v1/traces". Empty when the connection string contained no path.
	// Append it to Endpoint when constructing the collector URL.
	URLPath string

	// Insecure is true when the connection string used the "otlp" scheme
	// (plain HTTP). When false the connection uses TLS.
	Insecure bool

	// ServiceName is the value of the service_name query parameter, if present.
	ServiceName string

	// SampleRate is the resolved sample rate. Only meaningful when SamplerSet
	// is true; zero otherwise.
	SampleRate float64

	// SamplerSet is true when a sampler and sampler_param were explicitly
	// provided in the connection string.
	SamplerSet bool

	// Headers holds additional HTTP headers to send with every export
	// request. Populated from header_<Name>=<value> query parameters in the
	// connection string, e.g. header_Authorization=Bearer+token.
	Headers map[string]string
}

TracingConfig holds configuration parsed from a GitLab tracing connection string. Callers that receive a tracing URL from a source other than the environment (e.g. a JSON configuration document from Rails) can call ParseTracingConfig directly and use the fields to build their own exporter and provider with the OTEL SDK.

func ParseTracingConfig

func ParseTracingConfig(connectionString string) *TracingConfig

ParseTracingConfig parses a GitLab tracing connection string and returns the resulting TracingConfig. Returns nil when connectionString is empty or cannot be parsed.

Supported format:

otlp://host:port?service_name=X&sampler=probabilistic&sampler_param=0.01
otlps://host:port?...   (TLS / secure)

Sampler types:

  • probabilistic — sampler_param is a float in [0.0, 1.0]; use 0 to drop all, 1 to keep all

HTTP headers for the OTLP exporter can be set with header_<Name>=<value> query parameters:

otlp://host:4318?header_Authorization=Bearer+token&header_X-Custom=value
Example

ExampleParseTracingConfig shows how a service that receives a tracing URL from an external source (e.g. a Rails JSON response) can parse it and build its own exporter and provider using the OTEL SDK directly.

package main

import (
	"context"
	"log"

	"gitlab.com/gitlab-org/labkit/v2/trace"
	"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"

	sdktrace "go.opentelemetry.io/otel/sdk/trace"
)

func main() {
	ctx := context.Background()

	// urlFromRails is whatever the caller received over the wire.
	urlFromRails := "otlp://collector.internal:4318?service_name=runner&sampler=probabilistic&sampler_param=0.05"

	cfg := trace.ParseTracingConfig(urlFromRails)
	if cfg == nil {
		// URL was empty or unparseable — run without tracing.
		return
	}

	// Build exporter options directly from the parsed fields.
	var exporterOpts []otlptracehttp.Option
	if cfg.Endpoint != "" {
		exporterOpts = append(exporterOpts, otlptracehttp.WithEndpointURL(cfg.Endpoint+cfg.URLPath))
	}
	if cfg.Insecure {
		exporterOpts = append(exporterOpts, otlptracehttp.WithInsecure())
	}

	exporter, err := otlptracehttp.New(ctx, exporterOpts...)
	if err != nil {
		log.Fatal(err)
	}

	provider := sdktrace.NewTracerProvider(
		sdktrace.WithBatcher(exporter),
		sdktrace.WithSampler(cfg.Sampler()),
	)
	defer provider.Shutdown(ctx) //nolint:errcheck

	// Use the provider directly, or wrap it for the labkit convenience API.
	tracer := trace.NewWithProvider(provider)

	ctx, span := tracer.Start(ctx, "job-execution")
	defer span.End()
	_ = ctx
}

func ParseTracingConfigFromEnv

func ParseTracingConfigFromEnv() *TracingConfig

ParseTracingConfigFromEnv reads GitLabTracingEnvVar and parses it as a GitLab tracing connection string. Returns nil when the variable is unset, empty, or cannot be parsed.

Example

ExampleParseTracingConfigFromEnv shows how to read and parse the GITLAB_TRACING environment variable without going through New.

package main

import (
	"gitlab.com/gitlab-org/labkit/v2/trace"
)

func main() {
	cfg := trace.ParseTracingConfigFromEnv()
	if cfg == nil {
		// GITLAB_TRACING is unset or unparseable.
		return
	}

	// cfg.Endpoint, cfg.URLPath, cfg.Insecure, cfg.ServiceName, cfg.SampleRate
	// are available for constructing your own exporter and provider.
	_ = cfg.Sampler()
}

func (*TracingConfig) Sampler

func (c *TracingConfig) Sampler() sdktrace.Sampler

Sampler returns an sdktrace.Sampler for this config.

When [TracingConfig.SamplerSet] is true it returns sdktrace.TraceIDRatioBased using [TracingConfig.SampleRate]. When SamplerSet is false (no sampler was specified in the connection string) it returns sdktrace.ParentBased(sdktrace.AlwaysSample), which is the OpenTelemetry SDK default: sample all root spans and follow the parent decision for non-root spans.

Sampler is nil-safe: a nil receiver is treated as an absent config and returns sdktrace.ParentBased(sdktrace.AlwaysSample).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL