Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion components/exporters/otlphttp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Operator-supplied `headers` are sent verbatim on every outgoing request; useful

## Self-telemetry labels

The exporter increments `tracecore.exporter.calls_total{result, kind}` on every Consume*. The `kind` values emitted by otlphttp are NOT in the canonical set at `internal/selftelemetry/interface.go`; they're exporter-local low-cardinality strings:
The exporter increments `tracecore.exporter.calls_total{result, kind, component_id}` on every Consume*. The `kind` values emitted by otlphttp are exporter-local low-cardinality strings declared in [`selftel.go`](selftel.go) (sibling-scoped, package-local — see RFC-0013 §migration PR-B1; the `internal/selftelemetry` canonical set is being deleted in PR-F):

| `kind` | When | Operator first-step |
|---|---|---|
Expand Down
12 changes: 5 additions & 7 deletions components/exporters/otlphttp/classify_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,12 @@ import (
"testing"

"github.com/stretchr/testify/require"

"github.com/tracecoreai/tracecore/internal/selftelemetry"
)

func TestClassifyKind_PermanentStatusRoutesToDownstream(t *testing.T) {
t.Parallel()
err := fmt.Errorf("%w 400", errPermanentStatus)
require.Equal(t, selftelemetry.Kind("downstream"), classifyKind(err))
require.Equal(t, kindDownstream, classifyKind(err))
}

func TestClassifyKind_RetryableStatusRoutesToDownstream(t *testing.T) {
Expand All @@ -30,22 +28,22 @@ func TestClassifyKind_RetryableStatusRoutesToDownstream(t *testing.T) {
// io because the error message was "retryable status N", not
// "permanent status N". Reviewer P3-Rev1#7/Rev2-F3.
err := fmt.Errorf("%w 503", errRetryableStatus)
require.Equal(t, selftelemetry.Kind("downstream"), classifyKind(err))
require.Equal(t, kindDownstream, classifyKind(err))
}

func TestClassifyKind_CtxCanceledRoutesToIO(t *testing.T) {
t.Parallel()
require.Equal(t, selftelemetry.Kind("io"), classifyKind(context.Canceled))
require.Equal(t, kindIO, classifyKind(context.Canceled))
}

func TestClassifyKind_CtxDeadlineExceededRoutesToIO(t *testing.T) {
t.Parallel()
require.Equal(t, selftelemetry.Kind("io"), classifyKind(context.DeadlineExceeded))
require.Equal(t, kindIO, classifyKind(context.DeadlineExceeded))
}

func TestClassifyKind_GenericErrorRoutesToIO(t *testing.T) {
t.Parallel()
// Network-shaped errors (DNS, TLS, dial) fall through to the
// default branch and are tagged as io. README contract.
require.Equal(t, selftelemetry.Kind("io"), classifyKind(errors.New("dial tcp: no such host")))
require.Equal(t, kindIO, classifyKind(errors.New("dial tcp: no such host")))
}
14 changes: 7 additions & 7 deletions components/exporters/otlphttp/factory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,27 +13,27 @@ import (
"github.com/tracecoreai/tracecore/internal/pipeline/pipelinetest"
)

func TestFactory_TypeIsOtlphttp(t *testing.T) {
func TestOtlphttp_TypeIsOtlphttp(t *testing.T) {
t.Parallel()
require.Equal(t, "otlphttp", otlphttp.Factory.Type().String())
}

func TestFactory_CreateDefaultConfig_ReturnsConfigPointer(t *testing.T) {
func TestOtlphttp_CreateDefaultConfig_ReturnsConfigPointer(t *testing.T) {
t.Parallel()
cfg := otlphttp.Factory.CreateDefaultConfig()
_, ok := cfg.(*otlphttp.Config)
require.True(t, ok, "factory must produce *Config")
}

func TestFactory_NewFactoryReturnsSameInstance(t *testing.T) {
func TestOtlphttp_NewFactoryReturnsSameInstance(t *testing.T) {
t.Parallel()
// tools/components-gen calls NewFactory(); the package also
// exposes Factory directly. They must be the same value so
// in-code references and generated references stay aligned.
require.Same(t, otlphttp.Factory, otlphttp.NewFactory())
}

func TestFactory_CreateMetrics_ReturnsExporter(t *testing.T) {
func TestOtlphttp_CreateMetrics_ReturnsExporter(t *testing.T) {
t.Parallel()
fx := pipelinetest.New(t)
cfg := &otlphttp.Config{Endpoint: "http://localhost:4318"}
Expand All @@ -45,7 +45,7 @@ func TestFactory_CreateMetrics_ReturnsExporter(t *testing.T) {
require.True(t, ok, "exporter must implement consumer.Metrics")
}

func TestFactory_CreateTraces_ReturnsExporter(t *testing.T) {
func TestOtlphttp_CreateTraces_ReturnsExporter(t *testing.T) {
t.Parallel()
fx := pipelinetest.New(t)
cfg := &otlphttp.Config{Endpoint: "http://localhost:4318"}
Expand All @@ -57,7 +57,7 @@ func TestFactory_CreateTraces_ReturnsExporter(t *testing.T) {
require.True(t, ok, "exporter must implement consumer.Traces")
}

func TestFactory_CreateLogs_ReturnsExporter(t *testing.T) {
func TestOtlphttp_CreateLogs_ReturnsExporter(t *testing.T) {
t.Parallel()
fx := pipelinetest.New(t)
cfg := &otlphttp.Config{Endpoint: "http://localhost:4318"}
Expand All @@ -69,7 +69,7 @@ func TestFactory_CreateLogs_ReturnsExporter(t *testing.T) {
require.True(t, ok, "exporter must implement consumer.Logs")
}

func TestFactory_CreateMetrics_RejectsWrongConfigType(t *testing.T) {
func TestOtlphttp_CreateMetrics_RejectsWrongConfigType(t *testing.T) {
t.Parallel()
fx := pipelinetest.New(t)
// Passing a config of the wrong type should fail with a clear
Expand Down
70 changes: 46 additions & 24 deletions components/exporters/otlphttp/otlphttp.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import (

"github.com/tracecoreai/tracecore/internal/consumer"
"github.com/tracecoreai/tracecore/internal/pipeline"
"github.com/tracecoreai/tracecore/internal/selftelemetry"
)

// signal disambiguates which OTLP/HTTP path the exporter is bound
Expand Down Expand Up @@ -82,15 +81,10 @@ const (
maxResponseReadBytes = 64 * 1024
)

// Exporter-local selftelemetry kinds. `selftelemetry.Kind` is a
// string type and the interface explicitly allows per-component
// vocabulary; we declare ours so the exported metric labels stay
// low-cardinality (just these three values).
const (
kindMarshal selftelemetry.Kind = "marshal"
kindIO selftelemetry.Kind = "io"
kindDownstream = selftelemetry.KindDownstream
)
// Self-telemetry types (kind, selfExporter) live in `selftel.go`
// (sibling-scoped, package-local), not in `internal/selftelemetry` —
// see RFC-0013 §migration. PR-F deletes `internal/selftelemetry`; the
// sibling pattern is what survives.

// otlpExporter is the per-signal exporter the factory hands out.
// Goroutine-safe; net/http.Client handles concurrent calls.
Expand All @@ -99,7 +93,7 @@ type otlpExporter struct {

id pipeline.ID
logger *slog.Logger
telemetry selftelemetry.Exporter
telemetry selfExporter
transport *http.Transport
client *http.Client
cfg *Config
Expand Down Expand Up @@ -141,7 +135,7 @@ var (
errRetryableStatus = errors.New("otlphttp: server returned retryable status")
)

func newExporter(_ context.Context, set pipeline.CreateSettings, cfg *Config, sig signal) (*otlpExporter, error) {
func newExporter(ctx context.Context, set pipeline.CreateSettings, cfg *Config, sig signal) (*otlpExporter, error) {
endpoint, err := resolveEndpoint(cfg, sig)
if err != nil {
return nil, err
Expand Down Expand Up @@ -174,11 +168,11 @@ func newExporter(_ context.Context, set pipeline.CreateSettings, cfg *Config, si
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // operator-opted via config
}

te := newSelfTelemetry(set)
logger := set.Telemetry.Logger
if logger == nil {
logger = slog.New(slog.NewTextHandler(io.Discard, nil))
}
te := newSelfTelemetry(ctx, set, logger)
// insecure_skip_verify regressions get inherited from dev YAML into
// prod. WARN at startup so operators grepping pod logs catch it
// without having to render the ConfigMap.
Expand Down Expand Up @@ -282,23 +276,51 @@ func buildUserAgent(bi pipeline.BuildInfo) string {
}

// newSelfTelemetry wires the per-exporter self-telemetry handle.
// Returns a no-op when the MeterProvider is absent so the hot path
// never has to nil-check.
func newSelfTelemetry(set pipeline.CreateSettings) selftelemetry.Exporter {
// Returns a no-op when the MeterProvider is absent or instrument
// registration fails; the register-failure path also ticks
// `tracecore.selftelemetry.init_errors_total` via recordInitError so
// operators can alert on > 0. Mirrors the stdoutexporter sibling —
// same wire shape, no internal/selftelemetry import.
//
// NOTE on ExporterCarrier removal:
//
// v0.1.x otlphttp exposed `SelfExporter() selftelemetry.Exporter` so
// the runtime's reader-collection path could feed
// `tracecore.exporter.failure_rate`. The PR-B1 sibling port drops the
// `selftelemetry.ExporterCarrier` implementation:
//
// - The runtime path that consumed ExporterCarrier (`cmd/tracecore`
// in v0.1.x) silently skipped components that didn't implement it.
// There is no current production caller in this tree; the v0.1.x
// ConsumeCarrier was the only consumer and PR-F deletes it.
// - `tracecore_exporter_failure_rate` still appears in scrape via the
// SLO observable gauge (reports 0 with no readers registered).
// - `tracecore.exporter.calls_total{result,kind,component_id}`
// continues to surface because the sibling impl emits it on
// `set.Telemetry.MeterProvider` directly — dashboards / alerts
// keyed on the calls_total counter do not regress.
// - PR-F deletes `internal/selftelemetry` entirely, so the contract
// evaporates regardless. Removing now keeps the sibling
// import-graph clean and matches the stdoutexporter precedent.
//
// The per-exporter failure_rate gauge feed is the documented gap; the
// runtime degrades to the "no per-exporter signal" mode in line with
// the v0.1.x contract.
func newSelfTelemetry(ctx context.Context, set pipeline.CreateSettings, logger *slog.Logger) selfExporter {
if set.Telemetry.MeterProvider == nil {
return selftelemetry.NewNoopExporter()
logger.Warn("otlphttp: no MeterProvider; self-telemetry using noop")
return newNoopSelfExporter()
}
e, err := selftelemetry.NewExporter(set.ID, set.Telemetry.MeterProvider)
e, err := newSelfExporter(set.ID, set.Telemetry.MeterProvider)
if err != nil {
return selftelemetry.NewNoopExporter()
recordInitError(ctx, set.Telemetry.MeterProvider,
"exporter", set.ID.String(), reasonInstrumentRegister)
logger.Warn("otlphttp self-telemetry init failed; using noop", "err", err)
return newNoopSelfExporter()
}
return e
}

// SelfExporter exposes the selftelemetry handle so the runtime can
// register it for the M2 failure_rate observable gauge.
func (e *otlpExporter) SelfExporter() selftelemetry.Exporter { return e.telemetry }

// ErrShutdownSentinel exposes errShutdown for external test packages
// to match via errors.Is without parsing the error string. Package-
// internal callers use errShutdown directly.
Expand Down Expand Up @@ -661,7 +683,7 @@ func (e *otlpExporter) maybeCompress(body []byte) ([]byte, string, error) {
// classifyKind maps a transport error to a low-cardinality kind tag.
// Retry-exhausted 5xx remains downstream, NOT io: operators triaging
// by kind look at network for io and at the backend for downstream.
func classifyKind(err error) selftelemetry.Kind {
func classifyKind(err error) kind {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return kindIO
}
Expand Down
Loading
Loading