Home Resume
Homeβ€Ί Blogβ€Ί Week 10 - Centralised Logging Platform: One Query …
AWS Weekly Lab AWS Terraform

Centralised Logging Platform: One Query Across Every Account

Every account in an AWS organization hoards its own CloudWatch log groups β€” so "show me every error across the platform in the last hour" means logging into N accounts and running the same query N times, and log evidence dies with the account that produced it. This platform fixes both with CloudWatch's 2025-era native primitives: cross-account observability (OAM) for query-in-place, and organization-wide centralization rules for durable copies β€” no OpenSearch cluster, no Firehose pipeline, and the first centralized copy is free.

AWS Platform Engineering Lab Β· Week 10

Why β€” The Problem This Solves

By Week 10 this lab's organization has real shape: a management account, a Dev OU with an active workload account, and Week 6's account vending machine ready to mint more. Every one of those accounts accumulates its own CloudWatch log groups β€” Lambda functions, ECS services, Step Functions executions β€” and they are all invisible to each other.

That creates two problems that get worse with every account added:

  • Incident response scales linearly with account count. Answering "show me every ERROR across the platform in the last hour" means switching roles into each account, opening CloudWatch, and running the same Logs Insights query over and over. Ten accounts, ten logins, ten queries.
  • Log evidence has the same blast radius as the incident. The only copy of an account's logs lives inside that account. If the account is compromised, misconfigured, or deleted β€” the exact scenarios where you need its logs most β€” the logs go with it.

Teams historically didn't fix this because the fix was genuinely expensive: an OpenSearch cluster, Kinesis Firehose plumbing from every account, index lifecycle management β€” easily $1,000+/month at enterprise scale and a part-time job to operate. That calculus changed in 2025: AWS made cross-account querying native and made the first centralized log copy free. The gap today is knowing the new primitives exist, not affording them.

The pattern AWS is retiring β€” don't build it in 2026

The classic answer to this problem β€” subscription filters β†’ Kinesis Firehose β†’ OpenSearch β€” is now the architecture AWS itself is sunsetting. The official "Centralized Logging with OpenSearch" solution retires in December 2026, with AWS explicitly steering customers to CloudWatch's native centralization capabilities instead. This build uses the current primitives: OAM for sharing, centralization rules for copying, and Logs Insights (which now speaks OpenSearch PPL and SQL) for analysis.

What You Need to Know β€” Skills & Tools

OAM: sharing vs. copying

CloudWatch Observability Access Manager links accounts so a monitoring account can query logs and metrics in place β€” data never moves, costs nothing extra, and works across regions automatically. A sink in the monitoring account is the attachment point; each source account creates a link to it. Sharing is opt-in per account by design.

Centralization rules: the durable copy

A 2025-launched, Organizations-integrated feature: rules managed from the management (or delegated admin) account physically replicate matching log groups from any set of accounts/regions into one destination account. The first copy has no ingestion charge; only new data after rule creation is copied.

Log classes: an irreversible choice

The Infrequent Access class halves ingestion cost ($0.25/GB vs $0.50/GB) but permanently disables metric filters, subscription filters, and Live Tail β€” and a log group's class can never be changed after creation. This platform keeps destination groups Standard: the alarm needs a metric filter, and first-copy ingestion is free anyway, so IA would buy nothing.

Cross-account query fields

Logs Insights auto-indexes @aws.account and @aws.region on shared and centralized data, so one query can group errors by account across the organization β€” the two fields that make "one query, all accounts" real.

CloudWatch Logs CloudWatch OAM (sink + link) Logs Centralization rules Logs Insights (PPL/SQL) Lambda EventBridge SNS AWS Organizations Terraform (AWS provider 6.x) HCP Terraform Cloud

Architecture β€” How It Fits Together

Two accounts, two complementary data paths. The dashed path is OAM: the source account shares its logs and metrics with the monitoring account β€” nothing is copied, nothing is billed. The solid path is the centralization rule: log events are physically replicated into the management account, so the evidence survives even if the source account doesn't. A scheduled Lambda in the source account generates realistic structured traffic so every layer has live data to prove itself.

Centralised Logging Platform β€” share in place (OAM) + copy for keeps (centralization rule) Member account (source) ent-wkld-dev-sandbox Β· Dev OU EventBridge schedule rate(5 minutes) log_generator Lambda structured JSON Β· INFO / WARN / ERROR App log group /platform-lab/week10/log-generator 14-day retention OAM link (opt-in, per account) shares Logs + Metrics with the sink β€” no copy Management account monitoring + destination Centralized log group same name Β· streams tagged with source account + region Β· 30-day retention Β· Standard Metric filter β†’ alarm β†’ SNS β‰₯ 5 ERROR / 5 min β†’ email to owner Cross-account dashboard central errors + source Lambda metrics OAM sink org-scoped policy Β· logs + metrics only feeds share in place query cross-account Β· $0 physical copy first copy free Β· new data only Centralization rule scope: OrganizationId LogGroupName LIKE '/platform-lab/%'
1

EventBridge fires the log generator every 5 minutes

A scheduled rule in the source account invokes a tiny Lambda (128Β MB, ~60Β ms) that emits ~25 structured JSON log lines per run with weighted INFO/WARN/ERROR levels β€” realistic multi-service traffic, not lorem ipsum.

2

Logs land in the source account's app log group

The function's logging_config routes its stdout into /platform-lab/week10/log-generator (14-day retention) instead of the default /aws/lambda/* group, so one group carries the whole story.

3

OAM shares; the centralization rule copies

The OAM link makes the source account's logs and metrics queryable from the monitoring account instantly and for free. In parallel, the org-wide centralization rule replicates matching log groups into the management account β€” log streams tagged with source account and region.

4

The centralized copy is watched, not just stored

A metric filter counts ERROR-level events in the centralized stream; a CloudWatch alarm fires at 5+ errors in 5 minutes and publishes to SNS, which emails the platform owner. A cross-account dashboard shows the error metric next to the source account's Lambda metrics β€” the OAM part made visible.

How We Built It β€” Step by Step

Step 1 β€” Prerequisite: enable Organizations trusted access for CloudWatch

Do this before any Terraform β€” there is no resource for it

Centralization rules require Organizations trusted access for CloudWatch, enabled from the management account. The Terraform AWS provider has no resource for this (the feature request is still an open issue), so it's a one-time CLI/console step. The console path auto-creates the required service-linked role; the CLI path does not β€” you must create it explicitly, or rule creation fails later.

bash
aws organizations enable-aws-service-access \
  --service-principal observabilityadmin.amazonaws.com

# CLI path only: the console would create this SLR for you
aws iam create-service-linked-role \
  --aws-service-name logs-centralization.observabilityadmin.amazonaws.com

# verify
aws organizations list-aws-service-access-for-organization
CloudWatch settings Organization view in the management account showing organizational settings management status On and an empty centralization rules panel
CloudWatch Settings β†’ Organization view after enablement: trusted access On, centralization rules panel ready (and still empty at this point β€” the rule comes later).

Step 2 β€” Terraform layout: one root, two providers, five modules

One HCP Terraform workspace (week-10-dev, VCS-driven) applies everything. The root declares two providers: the default one for the management account, and an aliased one that assumes OrganizationAccountAccessRole in the source account β€” so hub and spoke resources live in one plan with no static credentials anywhere.

hcl
terraform {
  required_version = ">= 1.10"
  required_providers {
    # centralization-rule resource exists only since provider 6.21.0 β€”
    # this week cannot use the 5.x pin earlier weeks carry
    aws = { source = "hashicorp/aws", version = "~> 6.39" }
  }
  cloud {
    organization = "Katta"
    workspaces { name = "week-10-dev" }
  }
}

provider "aws" {          # management: sink, centralized copy, alerting
  region = var.aws_region
}

provider "aws" {          # source: log generator + OAM link
  alias  = "source"
  region = var.aws_region
  assume_role {
    role_arn = "arn:aws:iam::${var.source_account_id}:role/${var.source_role_name}"
  }
}

Five modules, each owning one concern: oam_hub (sink + org-scoped sink policy), oam_spoke (the link, under the source provider), log_generator (Lambda + schedule + app log group), log_centralization (the org-wide rule), and alerting (destination log group, metric filter, alarm, SNS, dashboard).

Step 3 β€” Deploy via HCP Terraform

Push to main, HCP plans remotely, confirm the apply. 17 resources across both accounts from one run β€” the aliased provider's assume_role is exercised during the plan itself, so a broken cross-account role fails fast at plan time, not at apply time.

HCP Terraform run history for the week-10-dev workspace
HCP Terraform run history for week-10-dev β€” VCS-driven plans from GitHub pushes, applies confirmed manually.

Step 4 β€” The OAM hub and spoke

The sink policy is the trust boundary: any account in the organization may link β€” so accounts minted by Week 6's vending machine join with zero config changes β€” but only for logs and metrics, enforced with a condition on oam:ResourceTypes.

hcl
resource "aws_oam_sink_policy" "this" {
  sink_identifier = aws_oam_sink.this.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = "*"
      Action    = ["oam:CreateLink", "oam:UpdateLink"]
      Resource  = "*"
      Condition = {
        StringEquals = { "aws:PrincipalOrgID" = var.organization_id }
        "ForAllValues:StringEquals" = {
          "oam:ResourceTypes" = ["AWS::CloudWatch::Metric", "AWS::Logs::LogGroup"]
        }
      }
    }]
  })
}
CloudWatch monitoring account view showing one linked source account sharing Logs and Metrics
The monitoring account after the apply: one linked source account, sharing Logs and Metrics through the OAM sink.

Step 5 β€” Live multi-account traffic

A platform demo with no data proves nothing, so the source account runs a generator: EventBridge invokes it every 5 minutes, and it emits weighted structured JSON β€” about 8% ERROR, 15% WARN, the rest INFO β€” across five fake services. Lambda is the right compute here for the same reason it keeps winning these one-shot jobs: millisecond duration, schedule-triggered, stateless, near-zero concurrency. (If this needed sustained hours-long load, an ECS task would be right; for high-throughput streaming, a Kinesis producer. Neither applies.)

Source account log group showing structured JSON log events at INFO, WARN and ERROR levels emitted by the generator Lambda
Structured JSON events landing in the source account's app log group β€” the raw material for everything downstream.

Step 6 β€” The centralization rule

One Terraform resource defines the org-wide copy: source scope is the entire organization, and a LIKE filter on log group names keeps the rule surgical β€” only the platform's own /platform-lab/ groups are centralized, not every log group in every account.

hcl
resource "aws_observabilityadmin_centralization_rule_for_organization" "this" {
  rule_name = var.rule_name
  rule {
    source {
      regions = [var.source_region]
      scope   = "OrganizationId = '${var.organization_id}'"
      source_logs_configuration {
        encrypted_log_group_strategy = "SKIP"
        log_group_selection_criteria = "LogGroupName LIKE '${var.log_group_prefix}%'"
      }
    }
    destination {
      account = var.destination_account_id
      region  = var.destination_region
    }
  }
}
Publish-time status: this one resource is waiting on AWS, not on the code

At publish time, this rule β€” written, validated, and retried automatically for over 24 hours β€” is still refused by AWS with "Centralization is currently initializing": the one-time organization onboarding behind the trusted-access switch has not completed. The full diagnostic story is ChallengeΒ 2 below. Everything else on this page is deployed and verified live; I'll update this section the moment the rule lands.

Step 7 β€” Analysis: one query, every account

Here's the part that makes the wait bearable: the "one query, every account" demo doesn't need the centralization rule at all β€” OAM sharing alone powers it. From the monitoring account, Logs Insights (now the "Log Analytics" experience, with the account scope set to All accounts) queries the source account's log groups in place:

logs insights
SOURCE logGroups(namePrefix: ["/platform-lab"]) START=-10800s END=0s
| fields @timestamp, @aws.account, level, service, message
| filter level = "ERROR"
| stats count() as errors by @aws.account, service
| sort errors desc
Log Analytics query in the monitoring account executing across 2 log groups with All accounts scope, returning error counts by service from the source account
Run from the monitoring account, scope "All accounts": the query executes across 2 log groups β€” the local one and the source account's, shared via OAM β€” and returns error counts per service across the org. 1,010 records scanned in 3.1 seconds.

The cross-account dashboard tells the same story in widget form β€” the right-hand widget reads the source account's Lambda metrics through OAM (note the xa cross-account badge):

CloudWatch dashboard in the monitoring account showing the centralized error metric widget, a cross-account widget of the source account's Lambda invocations, and the alarm state widget
The platform dashboard: centralized ERROR count (empty until the rule lands), the source account's generator metrics via OAM, and the alarm state.

Step 8 β€” Alerting: the platform pages someone

A logging platform that can't page anyone is a data lake. The metric filter turns ERROR-pattern events in the centralized stream into a number (ErrorCount in a custom namespace), the alarm watches it (β‰₯5 in 5 minutes), and SNS emails the owner β€” including an OK notification when the spike clears.

One human step first: SNS email subscriptions must be confirmed from the recipient's inbox before any notification flows — the confirmation mail goes out when Terraform creates the subscription. With that clicked, and the centralized stream still blocked upstream, the alarm→SNS→email path was tested honestly but synthetically: injecting ErrorCount=12 directly into the metric with aws cloudwatch put-metric-data. The alarm tripped inside two minutes and the email arrived — proving everything downstream of the metric filter. The filter itself gets its live end-to-end proof when centralized events start flowing.

CloudWatch alarms list showing week10-central-logging-error-spike in the In alarm state with actions enabled
week10-central-logging-error-spike in the In alarm state after the synthetic metric injection β€” the SNS email landed moments later.
SNS alarm notification email showing the week10-central-logging-error-spike alarm entering the ALARM state with threshold crossed details
The page itself: SNS's alarm email with the state change (OK β†’ ALARM), the reason, and the threshold β€” the platform telling a human something is wrong.

Challenges β€” What Actually Went Wrong

1The OAM link raced the sink policy β€” 403 "explicit deny" on first apply

First apply: 15 of 17 resources created, but aws_oam_link failed with AccessDeniedException ... explicit deny in a resource-based policy. The policy was correct β€” the identical CreateLink call succeeded from the CLI minutes later. The real cause: Terraform called CreateLink 14 seconds after creating the sink policy, before the resource policy had propagated. The dependency graph allowed it: the link references the sink's ARN, so nothing ordered it after the policy.

Fix

Module-level depends_on = [module.oam_hub] on the spoke module, so the link waits for everything in the hub β€” policy included. General lesson: when resource A must satisfy a resource-based policy created by resource B, referencing A's parent doesn't order it after B. Make the dependency explicit.

2"Centralization is currently initializing. Please try again later."

After enabling trusted access, CreateCentralizationRuleForOrganization refused with a ValidationException β€” and kept refusing, retried every few minutes for over 24 hours, while list/read APIs on the same service responded normally. Nothing in the docs mentions an initialization window at all. One suspect worth naming: this organization contains eight closed accounts sitting in their 90-day post-closure SUSPENDED state (decommissioned landing-zone leftovers), and an org-wide provisioning workflow that touches every member account may not enjoy meeting them.

It wasn't a CLI-only quirk either: the console's own rule wizard accepts the entire configuration and then rejects the final create with the same message β€” and the console's "Turn off trusted access" button greys out during initialization, so you can't even re-toggle it.

CloudWatch centralization rule wizard review page rejecting creation with the error Centralization is currently initializing, please try again later
The console wizard's review page: configuration fully valid, creation refused β€” the org-wide backend was still initializing many hours after trusted access was enabled.
Fix (in progress at publish time)

Ruled out everything ruleable-out: the SLR exists, trusted access shows On, a delegated administrator was registered, CloudTrail confirms the calls land and get rejected. The remaining moves are a scripted retry probe (running), and an AWS re:Post escalation. The architectural lesson stands regardless of when AWS finishes: never put an org-wide enablement with an unbounded activation time on your critical path β€” this build originally chained its alerting module behind the rule via an unnecessary depends_on, which held six perfectly deployable resources hostage to the outage (ChallengeΒ 5). This section gets a dated update when the rule lands.

5An unnecessary depends_on held the whole alerting layer hostage

The alerting module (SNS topic, alarm, metric filter, dashboard, destination log group) was wired with depends_on = [module.log_centralization] β€” a "feels safer" ordering with no real dependency behind it, since the module pre-creates its own log group. When the centralization rule got stuck in AWS's initialization outage, Terraform dutifully skipped all six alerting resources on every apply. The platform's alarm, email path, and dashboard didn't exist for a day β€” not because anything was wrong with them, but because of a dependency edge that didn't need to exist.

Fix

Deleted the depends_on; the six resources deployed immediately on the next apply, mid-outage. The mirror image of ChallengeΒ 1's lesson: add depends_on where a real invisible dependency exists (the OAM link racing the sink policy), and only there. Every dependency edge you add is a new way for one failure to become many.

3The CLI trusted-access path silently skips the service-linked role

AWS docs recommend enabling trusted access "through the console, which automatically creates the required service-linked role" β€” the CLI path (enable-aws-service-access) does no such thing, and nothing warns you. Without the SLR, rule creation fails later with a much less obvious error.

Fix

One explicit call: aws iam create-service-linked-role --aws-service-name logs-centralization.observabilityadmin.amazonaws.com. If you script org enablement (as IaC-minded people do), pair the two commands β€” always.

4The console has already outgrown the docs

The documentation says centralization rules live under CloudWatch Settings β†’ "Organization tab." In the current console, "Organization" is a segmented button on the settings page, not a tab β€” and the old-style deep link (#settings:organization) renders a spinner forever. Trivial once you know, disorienting when following the docs step by step.

Fix

Navigate CloudWatch β†’ Settings, then the Organization toggle at the top. A reminder that for features this new, the docs, console, CLI, and Terraform provider are four moving targets that don't move together β€” the same reason this build pins provider ~> 6.39 when other weeks still run 5.x.

Security β€” Controls at Every Layer

  • Org-scoped sink policy, resource-type limited. Only accounts inside the organization can link to the sink (aws:PrincipalOrgID condition), and only for logs and metrics (oam:ResourceTypes condition) β€” no account outside the org, no other telemetry types.
  • Log evidence survives source-account compromise. Centralized copies live in an account the source account has no write access to β€” the core security argument for physical copies over pure sharing.
  • No static credentials anywhere. HCP Terraform authenticates to AWS via OIDC; the spoke account is reached by role assumption from the management account; the SLR scopes what the centralization service itself can do.
  • Least-privilege generator. The Lambda's role can write to exactly one log group β€” nothing else.
  • Bounded retention as a control. 14 days at the source, 30 days centralized β€” logs are a liability as well as an asset; unbounded default retention is both a silent cost leak and a data-exposure surface.

Cost

ComponentDeployedNotes
OAM sink + link$0No charge for sharing logs/metrics cross-account
Centralization rule (first copy)$0 ingestionAdditional copies (e.g. a backup region) would be $0.05/GB
Log storage (both copies)~$0.03/GB-monthBounded by 14-day source / 30-day central retention
Lambda + EventBridge~$0~8,640 invocations/month at 128Β MB, deep in free tier
SNS email~$0First 1,000 email notifications/month free
Total< $2/monthDestroyed: $0

The comparison that matters: the retired Firehoseβ†’OpenSearch pattern for a 50-account org at 100Β GB/day runs $1,000–2,500/month before anyone queries anything. This design's equivalent: $0 first-copy ingestion, ~$90/month storage at 30-day retention, queries billed per GB scanned only when run.

Prices as of July 2026 β€” verify at aws.amazon.com/cloudwatch/pricing/.

Cleanup

The workspace is VCS-connected, so destroy runs in HCP: week-10-dev β†’ Settings β†’ Destruction and Deletion β†’ Queue destroy plan. After the destroy, verify three things aren't still lingering: log groups under /platform-lab/week10 in both accounts, the OAM sink/link pair, and the centralization rule. Organizations trusted access for CloudWatch is an org-level setting, not a billed resource β€” leaving it enabled costs nothing and saves the initialization wait next time.

Key Takeaways

  • Sharing and copying are different tools β€” run both. OAM answers "query everything now, for free"; centralization rules answer "keep evidence that survives the account that produced it." Neither replaces the other.
  • The 2025 primitives deleted a whole architecture. Three billed hops (subscription filter β†’ Firehose β†’ OpenSearch) became one free native feature. Re-check "best practice" architectures against the current platform before rebuilding them.
  • An irreversible, invisible choice: log class. Infrequent Access halves ingestion cost but permanently kills metric filters β€” decide per log group, at creation, with the alerting design in front of you.
  • Terraform's graph only knows what you reference. The OAM link raced a resource policy it never referenced. When correctness depends on a policy existing, depends_on is the honest fix, not a retry loop.
  • Brand-new AWS features have four clocks. Docs, console, CLI, and Terraform provider all described this feature slightly differently in the same week. Verify each layer live β€” including undocumented waits like the centralization backend's initialization.

Comments

How was your experience?
Your feedback helps improve this site.
PoorExcellent