Skip to content

feat: emit advanced-tier forwarder.* custom metrics - #48

Open
voorepreethi wants to merge 3 commits into
NR-601759-oci-observabilityfrom
NR-601759-oci-advanced-metrics
Open

feat: emit advanced-tier forwarder.* custom metrics#48
voorepreethi wants to merge 3 commits into
NR-601759-oci-observabilityfrom
NR-601759-oci-advanced-metrics

Conversation

@voorepreethi

Copy link
Copy Markdown
Contributor

Summary

Stacked on top of #47 (basic-tier forwarder.* metrics). Implements the advanced tier from the OCI Log Forwarder observability design doc: 11 more metrics on top of the 6 already shipped in basic, opt-in via metrics_tier = "advanced". none/basic behavior and cost are completely unaffected — every new metric is gated the same way the existing 6 already are.

What's added

Metric Where
forwarder.bytes.received after reading the payload in Unmarshal
forwarder.decode.errors (error_class) right before the existing panic on invalid JSON
forwarder.batches.created / forwarder.batch.size_bytes each time a batch is produced
forwarder.records.oversized a single record alone exceeds maxPayloadSize
forwarder.serialize.errors a record can't be marshaled for size estimation
forwarder.bytes.delivered on successful delivery — reuses the batch size already computed once while building the batch, rather than re-marshaling
forwarder.delivery.errors (error_class, status) delivery failure, alongside the existing records.dropped
forwarder.run.duration around the whole per-invocation processing
forwarder.secret.fetch.errors Vault/license-key fetch failure
forwarder.client.cache (`result=hit miss`)

Two things intentionally left out, both explained in code comments:

  • forwarder.delivery.retries — the design doc itself conditions this on "if/when client retry is added"; there's no retry mechanism in this codebase to observe, so faking it would give false confidence.
  • OCI-specific dimensions (compartment name, log_group, log_source_type) — compartment name (not OCID, to avoid a high-cardinality label) needs a new OCI Identity API dependency. Separate scope, left for a follow-up.

Testing

go build / go vet / go test ./... -race all clean.

Also deployed to a real OCI Function + New Relic account (not just unit tests) and confirmed all 11 metrics land with correct values — including the error paths: an invocation that panics on bad input correctly records invocations{status=error} with nothing else counted, and decode.errors fires with the right error_class.

Review

Went through this as a self-review pass before opening the PR: found and fixed one real bug (records.oversized was only checked when a record happened to start a fresh batch, so an oversized record arriving after another batch was already flushed slipped through uncounted — fixed to check unconditionally per record, with a regression test), plus a redundant nil guard, a duplicated comment, and the bytes.delivered double-marshal mentioned above.

Implements the advanced metrics_tier from the OCI Log Forwarder observability
design doc: 11 metrics on top of the 6 already shipped in basic (byte volumes,
decode/serialize errors, batching behavior, delivery error classes, run
duration, secret-fetch failures, client-cache hit rate). Opt-in via
FORWARDER_METRICS_TIER=advanced; none/basic customers are unaffected.

forwarder.delivery.retries is intentionally not implemented -- the design doc
itself conditions it on "if/when client retry is added", and no retry
mechanism exists in this codebase to observe. The suggested OCI-specific
dimensions (compartment name, log_group, log_source_type) are also deferred:
compartment name needs a new OCI Identity API dependency, which is meaningfully
separate scope.

bytes.delivered reuses the batch size already computed once while building the
batch (via a new BatchMessage wrapper carrying it through the channel) rather
than re-marshaling the whole batch again at delivery time.

Verified against a real OCI Function + New Relic account: all 11 metrics land
with correct values, including the error paths (decode.errors, and an
invocation that panics correctly recording invocations{status=error} with
nothing else counted).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@voorepreethi
voorepreethi deployed to build-test-env August 14, 2026 07:44 — with GitHub Actions Active
…name/

application_name dimensions, which now live on the base branch (PR #47)
instead of duplicated here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@voorepreethi
voorepreethi deployed to build-test-env August 17, 2026 07:46 — with GitHub Actions Active
// an oversized record arriving after another batch was already flushed would slip
// through uncounted.
if logSize > maxPayloadSize {
rec.Count(metrics.TierAdvanced, "forwarder.records.oversized", 1, nil)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we allow size more than 1MB?Will it not cause any issue at new relic side?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional, best-effort behavior rather than a gap: MaxPayloadSize (1MB) is New Relic's documented Log API limit (https://docs.newrelic.com/docs/logs/log-api/introduction-log-api/#limits). An oversized single record is still attempted so we don't silently drop data, but it's flagged via forwarder.records.oversized here, and if NR's API does reject it, that already surfaces via forwarder.delivery.errors/forwarder.records.dropped in newrelic_client_util.go. So the observability is already in place — happy to revisit if we'd rather actively drop/truncate oversized records instead of attempting delivery.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep close eye on observability.We dont want to silently drop also we dont want api to fail.

Comment thread logs-function/unmarshal/unmarshal.go
Comment thread logs-function/util/message_util.go Outdated
},
Entries: currentBatch,
}}
func ProduceMessageToChannel(channel chan BatchMessage, currentBatch common.LogData, attributes common.LogAttributes, sizeBytes int) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

channel <- BatchMessage{...} is an unconditional blocking send with no select/timeout/cancellation path. If the channel is full and nothing is draining it (e.g. all 6 workers stuck on a slow NR API call), this call just blocks until the invocation itself times out — there's no way for it to bail early or signal that it's stuck.
Lets try to handle it well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7e7d0e3: ProduceMessageToChannel now selects on ctx.Done() alongside the channel send and returns a bool, with ctx threaded through ProcessLogs/splitLogsIntoBatches/produceBatch from the invocation's own context. If the send can't complete because ctx is cancelled, we stop processing and record the affected records via forwarder.records.dropped (reason producer_cancelled) instead of blocking forever. Added a test (TestProduceMessageToChannel_ContextCancelled) covering the bail-out path.

ProduceMessageToChannel now selects on ctx.Done() alongside the channel
send, and the loggroup callers thread ctx through so a full channel with
no draining consumer no longer hangs until the OCI function's own hard
timeout kills the invocation. Records that couldn't be handed off are
now counted via forwarder.records.dropped instead of silently lost.
@voorepreethi
voorepreethi deployed to build-test-env August 21, 2026 11:38 — with GitHub Actions Active
Comment thread logs-function/main.go
nrClient, err := util.NewNRClient(rec)
if err != nil {
rec.Count(metrics.TierAdvanced, "forwarder.secret.fetch.errors", 1, nil)
log.Panicf("error initializing newrelic client: %v", err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line increments this metric on any non-nil error from NewNRClient, but that error can come from two unrelated places ,a bad NEW_RELIC_REGION (SetRegion failing) or an actual Vault fetch failure. A region misconfig gets mislabeled as a secret-fetch problem. Worse: once one real failure happens, the client-cache (lines 88-95) re-serves that same stale error on every invocation for up to 10 minutes (CLIENT_TTL), and each one re-increments the counter — one failure looks like a sustained outage.

Comment thread logs-function/main.go
log.Panicf("error initializing newrelic client: %v", err)
}

runStart := time.Now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one starts the timer after util.NewNRClient(rec) returns. On a cold client-cache, that call does a real network round-trip to OCI Vault. So the metric named "run duration" excludes exactly the part most likely to be slow — and if NewNRClient errors, log.Panicf (line 57) fires before the deferred timer is even registered, so the slowest/failing invocations contribute zero data points.

Fix: move runStart := time.Now() above the NewNRClient call.

rec.Summary(metrics.TierBasic, "forwarder.pipeline.lag", time.Since(env.Time).Seconds(), nil)
}

logBytes, err := json.Marshal(logData)

@pbhadra0112 pbhadra0112 Sep 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Records that fail to marshal vanish from the loss-accounting entirely.A record that fails json.Marshal is continued out and only counted in forwarder.serialize.errors, never in forwarder.records.dropped. Anyone reconciling received == delivered + dropped sees an unexplained gap and could wrongly conclude nothing was lost.

rec.Count(metrics.TierBasic, "forwarder.records.dropped", float64(remaining), map[string]interface{}{"reason": "producer_cancelled"})
log.Warnf("context cancelled while producing log batch; dropping %d remaining log record(s)", remaining)
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

produceBatch counts the batch it failed to send; the caller separately hand-computes
remaining := len(logs) - i
for the not-yet-batched tail. Correct today, but a future caller of produceBatch (a second batching path, a refactor) would naturally write if !produceBatch(...) { return } and get only half the accounting, silently undercounting drops — with zero test coverage on this exact path to catch it.

if !util.ProduceMessageToChannel(ctx, channel, batch, commonAttributes, batchSize) {
rec.Count(metrics.TierBasic, "forwarder.records.dropped", float64(len(batch)), map[string]interface{}{"reason": "producer_cancelled"})
log.Warnf("context cancelled while producing log batch; dropped %d log record(s)", len(batch))
return false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if len(currentBatch) == 0 {
currentBatch = common.LogData{logData}
currentBatchSize = logSize
} else if currentBatchSize+logSize > maxPayloadSize && len(currentBatch) > 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that second clause is always true there (the prior if len(currentBatch) == 0 branch already ruled out the empty case). Dead condition, safe to drop.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants