Skip to main content

OpenTelemetry Integration

Comprehensive distributed tracing for Cedar authorization decisions using OpenTelemetry.

Introduction

How cedar4s Creates Traces

The OpenTelemetryInterceptor automatically creates spans for each authorization check:

HTTP Request [500ms]
├─ Database Query [50ms]
├─ cedar.authorization [15ms] ← Automatic
│ ├─ cedar.principal.type: User
│ ├─ cedar.action: Document::View
│ ├─ cedar.decision: allow
│ └─ cedar.duration_ms: 15
└─ Render Response [100ms]

Spans include timing information, principal/action/resource attributes, decision details, and performance metrics.

Installation and Setup

Add Dependency

Add the OpenTelemetry module to your build.sbt:

libraryDependencies += "io.github.devnico" %% "cedar4s-observability-otel" % "0.0.0-SNAPSHOT"

This brings in cedar4s-client (core authorization) and opentelemetry-api (span creation). You'll also need an OpenTelemetry SDK and exporter for your backend.

Minimal Setup

The simplest integration uses GlobalOpenTelemetry:

import io.opentelemetry.api.GlobalOpenTelemetry
import cedar4s.observability.otel._
import cats.effect.IO

// Get tracer from global instance
val tracer = GlobalOpenTelemetry.getTracer("my-app")

// Create interceptor
val otelInterceptor = OpenTelemetryInterceptor[IO](tracer)

// Add to runtime
val runtime = CedarRuntime(engine, store, resolver)
.withInterceptor(otelInterceptor)

// All authorization checks now create spans automatically
val session = runtime.session(user)
session.require(Document.View(documentId))

For production configuration and backend setup, see the Backend Configuration section and OpenTelemetry Documentation.

Configuration

Basic OTLP Setup

OpenTelemetry Protocol (OTLP) is the vendor-neutral format for exporting traces:

import io.opentelemetry.sdk.OpenTelemetrySdk
import io.opentelemetry.sdk.trace.SdkTracerProvider
import io.opentelemetry.sdk.trace.export.BatchSpanProcessor
import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter
import io.opentelemetry.sdk.resources.Resource
import io.opentelemetry.semconv.ResourceAttributes

val resource = Resource.create(
Attributes.of(
ResourceAttributes.SERVICE_NAME, "my-service",
ResourceAttributes.SERVICE_VERSION, "1.0.0",
ResourceAttributes.DEPLOYMENT_ENVIRONMENT, "production"
)
)

val otlpExporter = OtlpGrpcSpanExporter.builder()
.setEndpoint("http://localhost:4317")
.build()

val tracerProvider = SdkTracerProvider.builder()
.addSpanProcessor(BatchSpanProcessor.builder(otlpExporter).build())
.setResource(resource)
.build()

val openTelemetry = OpenTelemetrySdk.builder()
.setTracerProvider(tracerProvider)
.buildAndRegisterGlobal()

Resource Attributes

Resources describe your service for proper trace organization:

val resource = Resource.create(
Attributes.of(
ResourceAttributes.SERVICE_NAME, "authorization-service",
ResourceAttributes.SERVICE_VERSION, "2.1.0",
ResourceAttributes.DEPLOYMENT_ENVIRONMENT, "production"
)
)

Sampling

Control which traces are recorded:

import io.opentelemetry.sdk.trace.samplers.Sampler

val sampler = Sampler.parentBased(Sampler.traceIdRatioBased(0.1))

val tracerProvider = SdkTracerProvider.builder()
.setSampler(sampler)
.addSpanProcessor(batchProcessor)
.build()

Recommendations:

  • Development: 100% sampling
  • Staging: 50% sampling
  • Production (low traffic): 10-25% sampling
  • Production (high traffic): 1-5% sampling

Cedar Semantic Conventions

Cedar4s follows OpenTelemetry semantic conventions for attribute naming. All Cedar attributes use the cedar. namespace.

AttributeDescription
cedar.principal.typeEntity type (e.g., "User")
cedar.principal.idEntity ID (may contain PII)
cedar.actionFull Cedar action
cedar.action.nameAction name only
cedar.resource.typeEntity type (e.g., "Document")
cedar.resource.idEntity ID (may be sensitive)
cedar.decision"allow" or "deny"
cedar.deny_reasonReason for denial (if denied)
cedar.duration_msDuration in milliseconds
cedar.entities.countEntities loaded for decision

Span Naming Strategies

Span names should be low cardinality (few unique values) to enable grouping and aggregation.

Default Strategy

Names all spans cedar.authorization:

val interceptor = OpenTelemetryInterceptor[IO](
tracer,
spanNamingStrategy = SpanNamingStrategy.default
)

By Action

Name spans by action type:

val interceptor = OpenTelemetryInterceptor[IO](
tracer,
spanNamingStrategy = SpanNamingStrategy.byAction
)
// Produces: cedar.authorization.View, cedar.authorization.Edit, etc.

By Resource Type

Name spans by resource type:

val interceptor = OpenTelemetryInterceptor[IO](
tracer,
spanNamingStrategy = SpanNamingStrategy.byResourceType
)
// Produces: cedar.authorization.Document, cedar.authorization.Folder, etc.

Custom Strategy

Implement arbitrary naming logic:

val customStrategy = SpanNamingStrategy { response =>
val prefix = response.principal.entityType match {
case "ServiceAccount" => "cedar.service"
case "User" => "cedar.user"
case _ => "cedar.other"
}
s"$prefix.${response.action.name}"
}

Never include high-cardinality values in span names (entity IDs, user IDs, request IDs). Use attributes for high-cardinality values, not span names.

Privacy Controls

AttributeFilter controls which attributes are included in spans.

Include All (Default)

Include all standard Cedar attributes:

val interceptor = OpenTelemetryInterceptor[IO](
tracer,
attributeFilter = AttributeFilter.includeAll
)

Minimal Filter

Include only essential attributes (excludes all entity IDs, full Cedar UIDs, deny reasons, and context information):

val interceptor = OpenTelemetryInterceptor[IO](
tracer,
attributeFilter = AttributeFilter.minimal
)

Exclude Entity IDs

Hide both principal and resource IDs:

val interceptor = OpenTelemetryInterceptor[IO](
tracer,
attributeFilter = AttributeFilter.excludeEntityIds
)

Custom Filter

Implement arbitrary filtering logic:

val customFilter = AttributeFilter { (attributeName, response) =>
attributeName match {
case SemanticConventions.CEDAR_PRINCIPAL_ID if isProd => false
case SemanticConventions.CEDAR_RESOURCE_ID =>
!response.resource.entityType.contains("Sensitive")
case _ => true
}
}

Additional filters available: excludePrincipalIds, excludeResourceIds, excludeDenyReasons.

Integration Patterns

HTTP Request Traces

Nest authorization spans within HTTP request traces:

import org.http4s._
import cats.effect.IO

def documentRoutes(
session: CedarSession[IO],
tracer: Tracer
): HttpRoutes[IO] = HttpRoutes.of[IO] {

case GET -> Root / "documents" / documentId =>
for {
_ <- session.require(Document.View(documentId))
document <- fetchDocument(documentId)
response <- Ok(document)
} yield response
}

// Trace structure:
// HTTP GET /documents/doc-123 [150ms]
// ├─ cedar.authorization [5ms]
// ├─ database.query [50ms]
// └─ render [95ms]

Cross-Service Tracing

OpenTelemetry automatically propagates trace context via HTTP headers when using auto-instrumented HTTP clients:

// Service A
def callServiceB[F[_]: Sync](
client: Client[F],
session: CedarSession[F]
): F[Response] = {
for {
_ <- session.require(External.CallServiceB())
response <- client.get("http://service-b/api/data")(identity)
} yield response
}

// Complete trace across services:
// Service A: call_service_b [200ms]
// ├─ cedar.authorization [5ms]
// └─ HTTP POST /api/data [195ms]
// ├─ cedar.authorization [3ms]
// └─ database.query [192ms]

Batch Operations

Track batch authorization checks:

def batchAuthorize[F[_]: Sync](
documentIds: List[String],
session: CedarSession[F],
tracer: Tracer
): F[List[Boolean]] = {
val span = tracer.spanBuilder("batch_authorize")
.setAttribute("batch.size", documentIds.size.toLong)
.startSpan()
val scope = span.makeCurrent()

try {
for {
results <- documentIds.traverse { id =>
session.check(Document.View(id))
}
} yield {
val allowed = results.count(identity)
span.setAttribute("batch.allowed", allowed.toLong)
results
}
} finally {
scope.close()
span.end()
}
}

Backend Configuration

Cedar4s uses standard OTLP exporters that work with any OpenTelemetry-compatible backend.

Generic OTLP Configuration

import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter

val exporter = OtlpGrpcSpanExporter.builder()
.setEndpoint("http://your-collector:4317")
.addHeader("authorization", "Bearer YOUR_API_KEY")
.build()
BackendEndpointDocumentation
Jaegerhttp://localhost:4317Jaeger OTLP
Zipkinhttp://localhost:9411/api/v2/spansZipkin OpenTelemetry
Honeycombhttps://api.honeycomb.io/v1/tracesHoneycomb OTLP
Datadoghttp://localhost:4318/v1/tracesDatadog OTLP
New Relichttps://otlp.nr-data.net:4318/v1/tracesNew Relic OTLP
AWS X-Rayvia OTel CollectorX-Ray OpenTelemetry
Google Cloud TraceUses Cloud Trace exporterGCP Trace

Production Best Practices

  • Use Batch Span Processor: Always use batch processing for async export, never SimpleSpanProcessor
  • Configure Appropriate Sampling: Use parent-based sampling, typically 1-10% in production
  • Minimize Attribute Cardinality: Use AttributeFilter to reduce span size
  • Graceful Shutdown: Ensure spans are exported before shutdown with Resource.make
  • Environment-Specific Configuration: Use different sampling and filtering per environment

Troubleshooting

ProblemSolutions
Spans not appearingVerify SDK is initialized; Check interceptor is added; Verify sampling; Check exporter configuration
Missing attributesCheck attribute filter; Temporarily use AttributeFilter.includeAll
High cardinality warningsUse low-cardinality span names; Move high-cardinality data to attributes; Use AttributeFilter.excludeEntityIds
Performance impactUse BatchSpanProcessor; Reduce sampling rate; Use AttributeFilter.minimal
Context not propagatingVerify W3C Trace Context propagators; Check HTTP headers contain traceparent