Skip to content

feat: automatic tenant_id injection in multi-tenant log output #359

@gandalf-at-lerian

Description

@gandalf-at-lerian

Problem

In multi-tenant mode, workers process messages from multiple tenants concurrently. When debugging issues in production (e.g., a report failing for a specific tenant), operators need to filter logs by tenant_id. Currently, the tenant ID is available in context (tmcore.GetTenantIDFromContext(ctx)) but is not automatically included in log output.

This means every log statement that needs tenant context requires manual effort:

logger.Log(ctx, log.LevelInfo, "processing report",
    log.String("tenant_id", tenantID),  // dev must remember this every time
    log.String("report_id", reportID),
)

Real-world impact

During the clotilde-dev stabilization (March 2026), we needed to trace which tenant was affected by specific errors in the reporter-worker. The logs showed:

ERROR  client/client.go:413  tenant manager returned error  {"status": 401, "body": "..."}
INFO   logcompat/logger.go:43  sync complete: 1 known, 1 active, 0 discovered, 0 removed
DEBUG  client/client.go:341  tenant config cache hit  {"tenant_id": "org_01KJZQAR17MPVQNQEHC228VQKK", ...}

Some log lines had tenant_id (the client cache hit), most did not. In a production environment with dozens of tenants, correlating errors to specific tenants requires grep-chaining trace IDs — slow and error-prone.

What we want

Every log line emitted within a tenant context should automatically include tenant_id without requiring the developer to pass it manually.

Proposed Solutions

Solution A: Logger Decorator (Recommended)

Create a TenantAwareLogger that wraps the base logger and extracts tenant_id from context on every Log() call:

// commons/tenant-manager/log/tenant_logger.go
type TenantAwareLogger struct {
    base log.Logger
}

func NewTenantAwareLogger(base log.Logger) *TenantAwareLogger {
    return &TenantAwareLogger{base: base}
}

func (l *TenantAwareLogger) Log(ctx context.Context, level log.Level, msg string, fields ...log.Field) {
    if tenantID := tmcore.GetTenantIDFromContext(ctx); tenantID != "" {
        fields = append(fields, log.String("tenant_id", tenantID))
    }
    l.base.Log(ctx, level, msg, fields...)
}

Pros:

  • Zero change required in existing service code
  • If applied in logcompat.New(), all consumer/manager logs get tenant_id for free
  • Simple implementation, easy to test
  • No risk of breaking existing behavior

Cons:

  • Adds a field even where it might not be needed (acceptable — tenant_id in a multi-tenant worker log is always useful)

Solution B: Context Field Extractors (More Extensible)

Register field extractor hooks that run on every log call:

// commons/log/context_fields.go
type ContextFieldExtractor func(ctx context.Context) []Field

var extractors []ContextFieldExtractor

func RegisterContextFieldExtractor(fn ContextFieldExtractor) {
    extractors = append(extractors, fn)
}

The tenant-manager package would register its extractor at init:

func init() {
    log.RegisterContextFieldExtractor(func(ctx context.Context) []log.Field {
        if id := tmcore.GetTenantIDFromContext(ctx); id != "" {
            return []log.Field{log.String("tenant_id", id)}
        }
        return nil
    })
}

Pros:

  • Extensible: any package can register extractors (request_id, trace_id, user_id)
  • Decoupled: log package doesn't need to know about tenant-manager
  • One mechanism for all contextual fields

Cons:

  • Higher implementation effort
  • Global mutable state (registry) requires care in tests
  • May be over-engineering if only tenant_id is needed now

Recommendation

Start with Solution A — it solves the immediate problem with minimal effort and zero risk. If demand grows for other context fields (request_id, user_id), evolve to Solution B later.

The ideal injection point is logcompat.New() in commons/tenant-manager/logcompat/ — this is already used by the consumer, MongoDB manager, and RabbitMQ manager. Wrapping the logger there gives automatic coverage to all multi-tenant components.

Related

Requested by @brunobls

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions