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
165 changes: 55 additions & 110 deletions components/exporters/otlphttp/selftel.go
Original file line number Diff line number Diff line change
@@ -1,35 +1,35 @@
// SPDX-License-Identifier: Apache-2.0

// Exporter-scoped self-telemetry surface. Replaces the v0.1.x
// dependency on `internal/selftelemetry`. Metric names follow the
// upstream OTel collector `otelcol_<role>_<component>_<metric>`
// convention per RFC-0013 §migration v0.1.0 namespace alignment:
// Exporter-scoped self-telemetry surface. Thin wrapper over
// module/pkg/selftel that pins this exporter's scope-name + instrument
// name + the kind enum. Metric names follow the upstream OTel collector
// `otelcol_<role>_<component>_<metric>` convention per RFC-0013
// §migration v0.1.0 namespace alignment:
// `otelcol.exporter.otlphttp.calls_total{result,kind,component_id}`
// (Prometheus exporter renders the dots as underscores). Label shape
// is preserved (`component_id`) so multi-instance disambiguation in
// dashboards is unchanged from v0.1.x. The instrumentation scope name
// is THIS exporter's Go import path — when the exporter moves under
// `module/` in PR-I, the scope name moves with it, matching OTel
// convention.
// (the Prometheus exporter renders the dots as underscores). Label
// shape is preserved (`component_id`) so multi-instance disambiguation
// in dashboards is unchanged from v0.1.x. The instrumentation scope
// name is THIS exporter's Go import path.
//
// Mirrors `components/exporters/stdoutexporter/selftel.go` (PR-B1).
// Mirrors `components/exporters/stdoutexporter/selftel.go`. Shared
// plumbing (the OTel counter, the noop fallback, the init-error
// fallback counter) lives in module/pkg/selftel.

package otlphttp

import (
"context"
"errors"
"fmt"

"go.opentelemetry.io/collector/component"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"

"github.com/tracecoreai/tracecore/module/pkg/selftel"
)

// kind is a low-cardinality error-class identifier for exporter failures.
// Mirrors the internal/selftelemetry.Kind type so the migration is
// mechanical; exporter-local because the canonical-Kind enforcement that
// the internal package owned moves into RFC-0013 PR-I's submodule.
// Exporter-local so the wire-format strings stay owned by the package
// that emits them; the canonical-Kind enforcement the deleted
// internal/selftelemetry package owned moves into RFC-0013's submodule.
type kind string

const (
Expand All @@ -47,38 +47,29 @@ const (
kindDownstream kind = "downstream"
)

// reasonInstrumentRegister labels init_errors_total ticks when OTel
// instrument registration failed at construction time.
const reasonInstrumentRegister = "instrument_register"

// instrumentationScope pins the OTel scope name. Per OTel convention,
// the scope is the package's Go import path; PR-I changes this when
// the exporter moves into the module/ submodule.
// the scope is the package's Go import path.
const instrumentationScope = "github.com/tracecoreai/tracecore/components/exporters/otlphttp"

// errNilMeterProvider mirrors selftelemetry.ErrNilMeterProvider — the
// factory is responsible for substituting the noop fallback + ticking
// init_errors_total. Returning a sentinel rather than a generic error
// lets the factory distinguish "wire-up bug" from "instrument register
// failure" if it ever needs to.
var errNilMeterProvider = errors.New("otlphttp: MeterProvider is nil")

// selfExporter is the exporter-scoped self-health surface. Methods are
// non-blocking + safe for concurrent use; the noop impl discards.
// Mirrors the internal/selftelemetry.Exporter interface but trimmed to
// the exact surface otlphttp uses — no FailureRateReader (the
// per-exporter failure_rate aggregation contract is intentionally
// dropped by this port; see the package comment on SelfExporter
// removal in otlphttp.go).
//
// Why drop FailureRateReader / ExporterCarrier:
// - The runtime path that consumed ExporterCarrier
// (`cmd/tracecore.collect.collectFailureRateReaders` in v0.1.x)
// was deleted by RFC-0013 PR-A2 along with the hand-wired entry
// point, so the carrier interface has no remaining consumer.
// - `internal/selftelemetry` (which owned the carrier) was deleted
// by RFC-0013 PR-F.1. Operators rate-derive failure rate via
// PromQL `rate(otelcol_exporter_otlphttp_calls_total{result="error"}[5m])`.
// callsTotalName is the operator-facing metric name for this exporter's
// per-call counter. Kept here (not in module/pkg/selftel) so the
// shared package stays unaware of caller-specific name choices.
const callsTotalName = "otelcol.exporter.otlphttp.calls_total"

// reasonInstrumentRegister is the wire-format label value for
// init_errors_total ticks when OTel instrument registration failed at
// construction time. Re-exported from the shared package so this
// package's factory + tests don't import selftel just for the const.
const reasonInstrumentRegister = selftel.ReasonInstrumentRegister

// errNilMeterProvider is the sentinel returned by newSelfExporter when
// called with a nil MeterProvider. Aliased to the shared sentinel so
// the factory's errors.Is check survives the migration.
var errNilMeterProvider = selftel.ErrNilMeterProvider

// selfExporter is the exporter-scoped self-health surface used by
// otlphttp hot paths. Mirrors selftel.Exporter but carries the
// package-local `kind` type so call sites stay type-checked.
type selfExporter interface {
IncCallSuccess()
IncCallFailure(k kind)
Expand All @@ -94,79 +85,33 @@ func (noopSelfExporter) IncCallFailure(kind) {}

var _ selfExporter = noopSelfExporter{}

// newSelfExporter returns a real selfExporter backed by an OTel counter
// `otelcol.exporter.otlphttp.calls_total{result, kind, component_id}` acquired
// from mp. The component's id is attached as the `component_id` label
// on every emission. Metric name + label shape preserved from the
// v0.1.x internal selftelemetry package so dashboards / alerts don't
// regress.
// newSelfExporter returns a real selfExporter backed by the shared
// selftel.Exporter wired at this package's scope + calls_total name.
// Returns errNilMeterProvider (== selftel.ErrNilMeterProvider) when mp
// is nil; the factory is responsible for the noop fallback + the
// init_errors_total tick via recordInitError.
func newSelfExporter(id component.ID, mp metric.MeterProvider) (selfExporter, error) {
if mp == nil {
return nil, errNilMeterProvider
}
meter := mp.Meter(instrumentationScope)

calls, err := meter.Int64Counter(
"otelcol.exporter.otlphttp.calls_total",
metric.WithDescription("Exporter Consume* calls partitioned by result"),
)
inner, err := selftel.NewExporter(id.String(), instrumentationScope, callsTotalName, mp)
if err != nil {
return nil, fmt.Errorf("exporter.calls_total counter: %w", err)
return nil, err
}

return &selfExporterImpl{
componentID: id.String(),
calls: calls,
}, nil
return &selfExporterImpl{inner: inner}, nil
}

var _ selfExporter = (*selfExporterImpl)(nil)

// selfExporterImpl casts the package-local `kind` to string at the
// shared-package seam. Zero-cost — the cast is a compile-time op.
type selfExporterImpl struct {
componentID string
calls metric.Int64Counter
inner selftel.Exporter
}

func (e *selfExporterImpl) IncCallSuccess() {
// Emit component_id + result in one WithAttributes call rather than
// merging two attribute sets — avoids relying on SDK merge semantics
// that vary across OTel versions. Mirrors the stdoutexporter sibling.
e.calls.Add(context.Background(), 1, metric.WithAttributes(
attribute.String("component_id", e.componentID),
attribute.String("result", "success"),
))
}
var _ selfExporter = (*selfExporterImpl)(nil)

func (e *selfExporterImpl) IncCallFailure(k kind) {
e.calls.Add(context.Background(), 1, metric.WithAttributes(
attribute.String("component_id", e.componentID),
attribute.String("result", "failure"),
attribute.String("kind", string(k)),
))
}
func (e *selfExporterImpl) IncCallSuccess() { e.inner.IncCallSuccess() }
func (e *selfExporterImpl) IncCallFailure(k kind) { e.inner.IncCallFailure(string(k)) }

// recordInitError ticks otelcol.selftelemetry.init_errors_total when
// exporter wiring falls back to noop telemetry. Operators alert on
// `> 0` to learn that self-telemetry isn't really plugged in. Panics
// from a broken MeterProvider are swallowed — recordInitError IS the
// degraded-path fallback; crashing here would turn a partial outage
// into a process kill.
// recordInitError forwards to the shared selftel.RecordInitError with
// this package's scope. Kept as a thin wrapper so the factory's call
// site stays identical to the pre-refactor shape.
func recordInitError(ctx context.Context, mp metric.MeterProvider, kindLabel, componentID, reason string) {
defer func() { _ = recover() }()
if mp == nil {
return
}
meter := mp.Meter(instrumentationScope)
c, err := meter.Int64Counter(
"otelcol.selftelemetry.init_errors_total",
metric.WithDescription("Counter of self-telemetry construction failures that fell back to the noop implementation."),
)
if err != nil {
return
}
c.Add(ctx, 1, metric.WithAttributes(
attribute.String("kind", kindLabel),
attribute.String("component_id", componentID),
attribute.String("reason", reason),
))
selftel.RecordInitError(ctx, mp, instrumentationScope, kindLabel, componentID, reason)
}
Loading
Loading