Trying Out CloudWatch Database Insights with Support for PostgreSQL on EC2
This page has been translated by machine translation. View original
Introduction
On September 1, 2026, CloudWatch Database Insights added support for self-managed PostgreSQL. A PostgreSQL you operate yourself now shows up on the same Database Insights dashboard as RDS and Aurora.
I set it up on PostgreSQL 16 running on an EC2 t3.micro and checked what appears in the console and what arrives in CloudWatch. I also traced how the agent collects and sends data, based on the effective configuration after expansion.
What I tested
The CloudWatch agent runs on the same host as PostgreSQL. The prerequisites in the user guide also state that monitoring a remote database is not supported. The connection target is localhost, and the server log is read from a local file. For this test I put everything on a single EC2 instance.
Test environment
The environment is a single EC2 instance in us-west-2: a t3.micro (gp3 8GB) running Amazon Linux 2023. I installed PostgreSQL 16.15 (postgresql16-server-16.15-1.amzn2023.0.1.x86_64). The CloudWatch agent is 1.300072.0b1766, the latest version I could get from the S3 distribution URL. Shell access was through Session Manager only, and I did not open any security group inbound rules.
I attached two managed policies to the instance profile.
InstanceRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: ec2.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
- arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy
AmazonSSMManagedInstanceCore is attached for Session Manager and Run Command. The only policy Database Insights requires is CloudWatchAgentServerPolicy.
I created this environment with CloudFormation.
aws cloudformation deploy \
--region us-west-2 \
--stack-name dbinsights-selfmanaged-pg \
--template-file template.yaml \
--capabilities CAPABILITY_IAM \
--parameter-overrides InstanceType=t3.micro
PostgreSQL-side settings
Database Insights reads PostgreSQL's standard views, so PostgreSQL must be configured first. I put the following in postgresql.conf.
shared_preload_libraries = 'pg_stat_statements'
track_activities = on
track_activity_query_size = 4096
password_encryption = 'scram-sha-256'
pg_stat_statements.max = 10000
pg_stat_statements.track = all
pg_stat_statements.track_planning = on
logging_collector = on
log_directory = 'log'
log_filename = 'postgresql-%a.log'
log_rotation_age = 1d
log_rotation_size = 0
log_truncate_on_rotation = on
log_min_duration_statement = 500
log_line_prefix = '%m [%p] %q%u@%d '
compute_query_id = on
shared_preload_libraries is read only at startup, so applying it requires a restart, not a reload.
systemctl restart postgresql
Next I enabled the extension, created a monitoring user, and allowed its connection. pg_stat_statements is an extension, so you create it in each database you want to monitor.
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
CREATE ROLE cw_monitor WITH LOGIN PASSWORD '<パスワード>';
GRANT pg_monitor TO cw_monitor;
# TYPE DATABASE USER ADDRESS METHOD
host all cw_monitor 127.0.0.1/32 scram-sha-256
host all cw_monitor ::1/128 scram-sha-256
pg_hba.conf is evaluated from the top, and the first matching line is used. The default configuration of Amazon Linux 2023 accepts loopback connections with ident, so these two lines must be placed above the existing lines. This one can be applied with a reload.
systemctl reload postgresql
Granting the pg_monitor role lets the user read pg_stat_activity and pg_stat_statements without superuser. After applying the settings, I connected to localhost as the monitoring user and confirmed that both views were readable.
Connected as the monitoring user, the following values were returned.
postgres (PostgreSQL) 16.15
activity_rows
---------------
6
(1 row)
statement_rows
----------------
58
(1 row)
shared_preload_libraries
--------------------------
pg_stat_statements
(1 row)
Agent configuration
On the CloudWatch agent side, you add just one section. The content of /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json ended up as follows.
{
"agent": {
"region": "us-west-2"
},
"opentelemetry": {
"collect": {
"database_insights": {
"postgresql": [
{
"endpoint": "localhost:5432",
"instance_name": "selfmanaged-pg-1",
"username": "cw_monitor",
"password_file": "/opt/aws/amazon-cloudwatch-agent/etc/pgpass",
"logs": {
"file_path": "/var/lib/pgsql/data/log/postgresql-*.log"
}
}
]
}
}
}
}
instance_name becomes the display name in the console. The password is not placed in the configuration file but in the file that password_file points to. The format is libpq's pgpass.
The only items you can specify are the six: region, endpoint, instance_name, username, password_file, and logs.file_path. There is no item for choosing which metrics to collect. The parameter table in the user guide lists the same six items. What was actually collected is described later.
I created the pgpass file and loaded the configuration.
umask 077
printf 'localhost:5432:*:cw_monitor:%s\n' "$MON_PASS" \
> /opt/aws/amazon-cloudwatch-agent/etc/pgpass
chown cwagent:cwagent /opt/aws/amazon-cloudwatch-agent/etc/pgpass
/opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl \
-a fetch-config -m ec2 -s \
-c file:/opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json
I changed the owner of pgpass so that the cwagent user can read it. After loading, the agent returned the following status.
{
"status": "running",
"starttime": "2026-09-03T01:53:35+00:00",
"configstatus": "configured",
"version": "1.300072.0b1766"
}
The configuration file I wrote has no item for the collection interval. The intervals appear in the effective configuration expanded by config-translator. The expanded amazon-cloudwatch-agent.yaml is 51,220 bytes. The interval for each collection is as follows.
| Collection | Interval |
|---|---|
| postgresql/metrics_0 | 10s |
| query_sample_collection (pg_stat_activity) | 1s |
| top_query_collection (pg_stat_statements) | 1m |
| postgresql/metrics_perresource_0 | 1m |
| postgresql/events_0 | 10s |
| hostmetrics/opentelemetry | 30s |
Session samples are taken every second, and the upper limit of rows read at one time is 500. Top query collection runs every minute and targets the top 200. Execution plans are fetched up to 1000 per interval and are stored in a cache of 1000 entries with a 1-hour TTL.
The process scraper of the host metrics targets only processes that match post(gres|master).*. The filelog that follows the server log sets start_at to end and parses the timestamp and severity with a regular expression. The filter processor drops log records whose user name or role name is cw_monitor, so the monitoring user's own queries do not appear in raw-events.
Data reaches CloudWatch every minute, and the difference between unique event timestamps was 60 seconds for both pg_stat_statements-derived and pg_stat_activity-derived events. In periods when I was not running any load, there were time ranges with no data points.
The agent itself connects to PostgreSQL to collect. The configured endpoint and username are expanded as is into the postgresqlreceiver, and the transport is tcp. For the password, it references pgpass as a passfile. What it sends are SELECTs against standard views. The targets include pg_stat_activity, pg_stat_statements, pg_stat_database, pg_locks, pg_stat_user_indexes, pg_stat_user_functions, pg_database_size(), SHOW max_connections, and SHOW server_version.
When I looked with no load running, the monitoring user had 8 idle connections resident.
For transfer, the agent sends data in batches via OTLP/HTTP. It does not use PutMetricData.
otlphttp/metrics:
metrics_endpoint: https://monitoring.us-west-2.amazonaws.com/v1/metrics
compression: gzip
encoding: proto
sending_queue:
enabled: true
num_consumers: 10
queue_size: 1000
retry_on_failure:
enabled: true
initial_interval: 5s
max_interval: 30s
max_elapsed_time: 5m0s
multiplier: 1.5
timeout: 30s
otlphttp/logs:
logs_endpoint: https://logs.us-west-2.amazonaws.com/v1/logs
compression: gzip
encoding: proto
The batching conditions are written in the batch processor.
| Target | send_batch_size | send_batch_max_size | timeout |
|---|---|---|---|
| Metrics | 1000 | 1000 | 30s |
| Logs | 10000 | 10000 | 30s |
Authentication is sigv4auth. The SigV4 service name is monitoring for metrics and logs for logs. The awscloudwatchlogsprovisioner extension is responsible for creating the log group and log stream. The destination is routed by the x-aws-log-group and x-aws-log-stream headers that headers_setter adds. The batch on the log side has the log group name and log stream name as metadata keys.
I also measured the cost of the collection itself. These are the values on a single t3.micro after running no load at all, resetting pg_stat_statements, and leaving it for 300 seconds.
- The agent process's CPU time was 7.40 seconds / 300 seconds, which is 2.47% of one core
- The agent process's RSS was 140,072 KB, and ps's %MEM was 14.9%
- The monitoring user's queries were 980 calls / 221.94 ms in total
- The only one at a 1-second interval was the single query reading pg_stat_activity, at 305 calls / 55.60 ms (mean 0.182 ms). The 19 queries at a 10-second interval had 35 calls each, and the one reading pg_stat_statements had 10 calls
- The query with the longest execution time per call was the one reading pg_database_size, with a mean of 1.535 ms
- The server log grew by only 22 lines in 300 seconds
The load on the DB side is small, and what you always pay is 140 MB of memory and 2.5% CPU on the agent side. In a configuration that co-locates it with PostgreSQL on a t3.micro, memory becomes the problem first. As the number of executed SQL statements and the amount of query logs increase, the load on the collection side also increases.
You can tell whether collection has started from the agent log. The following lines appeared.
I! {"caller":"builders/builders.go:26","msg":"Development component. May change in the future."}
I! {"caller":"awscloudwatchlogsprovisionerextension@v0.124.1/extension.go:79","msg":"awscloudwatchlogsprovisioner started","region":"us-west-2"}
I! {"caller":"service@v0.124.0/service.go:289","msg":"Everything is ready. Begin running and processing data."}
I! {"caller":"fileconsumer/file.go:265","msg":"Started watching file","component":"fileconsumer","path":"/var/lib/pgsql/data/log/postgresql-Thu.log"}
The only policies I attached were the two shown in the test environment, but not a single AccessDenied appeared in the log. The collection pipeline configuration output in the same log includes postgresql/metrics_0, count/dbi_dbload, and signaltometrics/dbi_topsql.
Console display
To produce data for the console, I injected load with SSM Run Command. For 10 minutes I ran an aggregate SELECT against a 300,000-row table, INSERT and UPDATE, slow queries with pg_sleep in between, and transactions that update the same rows against each other.
The command I sent is as follows. commands.json contains the SQL that creates the orders table with 300,000 rows and a shell script that runs the load above.
aws ssm send-command \
--region us-west-2 \
--instance-ids i-xxxxxxxxxxxxxxxxx \
--document-name AWS-RunShellScript \
--parameters file://commands.json
The period during which load was running is from 2026-09-03T01:54:23Z to 02:04:26Z. The top entry in PostgreSQL's pg_stat_statements was SELECT count(*) FROM orders WHERE note LIKE $1 AND amount > (random()*. calls was 1464 and total_exec_time was 1049332.9 ms.

A database load graph, a legend by wait event, and a list of top SQL are laid out. The following is the display after running load just once on a single t3.micro.
The name shown in the list of database instances was selfmanaged-pg-1, using the instance_name from the agent configuration as is. The card showed "DB load utilization 94.1%", and the header showed the role as Instance, the engine as PostgreSQL, and the size as t3.micro.
Database load is displayed as average active sessions (AAS). During the period when load was running, bars peaking at 2 to 3 appeared. The legend lists CPU, BtreePage, ExecuteGather, PgSleep, WALSync, transactionid, and a dashed line for "Max vCPU".
There were 10 top SQL entries. The values of load by wait (AAS) from the top were 0.52, 0.38, 0.31, 0.28, 0.24, and 0.14. The remaining four were below 0.01. For three of the 10, the SQL text could not be obtained, and they were shown as Unknown with the Query ID attached. Right after opening the dashboard, it showed "Top SQL (0)" with "Loading data", and then the 10 entries appeared.
There are two views, "Fleet health" and "Database instances", and the Cross-account cross-region mode toggle is in the same position as in RDS.
Telemetry that arrived
The agent created two log groups.
/aws/self-managed-database-insights/postgresql/raw-events
/aws/self-managed-database-insights/postgresql/server-logs
Neither was created in the template. The log stream name for both is in the form i-xxxxxxxxxxxxxxxxx/selfmanaged-pg-1, combining the instance ID and the configured instance_name. These log groups are outside CloudFormation's management, so they remain even if you delete the stack.
OTel logs with literals replaced by ? arrived in raw-events. Of the 223 events I retrieved, 169 were statistics derived from pg_stat_statements, and 54 were session samples derived from pg_stat_activity.
The form of one event is as follows.
{
"resource": {
"attributes": {
"service.instance.id": "ip-10-20-x-x.us-west-2.compute.internal:5432",
"cloud.provider": "aws",
"cloud.platform": "aws_ec2",
"cloud.region": "us-west-2",
"cloud.account.id": "123456789012",
"cloud.availability_zone": "us-west-2a",
"host.id": "i-xxxxxxxxxxxxxxxxx",
"host.image.id": "ami-0bea529386a62a2ad",
"host.type": "t3.micro",
"host.name": "ip-10-20-x-x.us-west-2.compute.internal",
"db.system.name": "postgresql",
"db.instance.name": "selfmanaged-pg-1",
"deployment.environment.name": "aws_ec2:default",
"cloud.resource_id": "arn:aws:ec2:us-west-2:123456789012:instance/i-xxxxxxxxxxxxxxxxx",
"service.name": "unknown_service"
},
"schemaUrl": "https://opentelemetry.io/schemas/1.6.1"
},
"scope": {
"name": "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/postgresqlreceiver",
"version": "1.300072.0b1766",
"attributes": {
"cloudwatch.source": "cloudwatch-agent",
"cloudwatch.solution": "otel-database-insights"
}
},
"timeUnixNano": 1788400477424151302,
"observedTimeUnixNano": 0,
"severityNumber": 0,
"severityText": "",
"attributes": {
"db.system.name": "postgresql",
"db.namespace": "appdb",
"db.query.text": "BEGIN UPDATE orders SET amount = amount + ? WHERE id = ? SELECT pg_sleep ( ? ) COMMIT",
"user.name": "postgres",
"postgresql.state": "active",
"postgresql.pid": 28140,
"postgresql.application_name": "psql",
"network.peer.address": "",
"network.peer.port": -1,
"postgresql.client_hostname": "",
"postgresql.query_start": "2026-09-03 01:54:36.888193+00",
"postgresql.wait_event": "transactionid",
"postgresql.wait_event_type": "Lock",
"postgresql.query_id": "686538199038618172",
"postgresql.total_exec_time": 625.21
},
"traceId": "",
"spanId": "",
"eventName": "db.server.query_sample"
}
The eventName of the event shown is db.server.query_sample, which corresponds to a pg_stat_activity sample. Events derived from pg_stat_statements have db.query.text, postgresql.calls, postgresql.rows, postgresql.total_exec_time, postgresql.total_plan_time, postgresql.shared_blks_hit, postgresql.shared_blks_read, postgresql.shared_blks_dirtied, postgresql.shared_blks_written, postgresql.temp_blks_read, postgresql.temp_blks_written, postgresql.queryid, and postgresql.rolname. Even for the same query, postgresql.calls is smaller than the cumulative value in pg_stat_statements.
The breakdown by wait event that I saw in the console can be pulled in the same way with Logs Insights.
filter ispresent(`attributes.postgresql.state`)
| stats count(*) as samples
by coalesce(`attributes.postgresql.wait_event_type`, "CPU") as wait_type,
coalesce(`attributes.postgresql.wait_event`, "CPU") as wait_event
| sort samples desc
| wait_type | wait_event | samples |
|---|---|---|
| CPU | CPU | 27 |
| Timeout | PgSleep | 17 |
| Lock | transactionid | 9 |
| IO | WALSync | 1 |
This is the aggregation of the 54 events that have postgresql.state, out of the 181 events Logs Insights scanned over the target period (the retrieval range differs from the 223 above). Events without a wait event type are counted as CPU.
Top queries can also be obtained from the same log group.
filter ispresent(`attributes.postgresql.total_exec_time`)
| stats max(`attributes.postgresql.total_exec_time`) as total_exec_ms,
max(`attributes.postgresql.calls`) as calls
by substr(`attributes.db.query.text`, 0, 60) as query
| sort total_exec_ms desc | limit 8
Eight rows were returned; here are the top four.
| query (first 60 characters) | total_exec_ms | calls |
|---|---|---|
| BEGIN UPDATE orders SET amount = amount + ? WHERE id = ? SEL | 3585.866 | - |
| SELECT pg_sleep ( ? ) count ( * ) FROM orders | 975.3 | 40 |
| UPDATE orders SET status = ? WHERE id = ( SELECT id FROM ord | 830.038 | 121 |
| SELECT count ( * ) FROM orders WHERE note LIKE ? AND amount | 609.541 | 150 |
The calls here is the series of values carried on the events, a different quantity from the cumulative value (1464) in pg_stat_statements. The top entry was an event without calls.
Now the metrics side. I wrote only one section in the configuration file, but the agent built multiple pipelines.
metrics/host_metrics:
exporters:
- forward/opentelemetry
processors:
- transform/host_metrics_scope
receivers:
- hostmetrics/opentelemetry
metrics/opentelemetry:
exporters:
- otlphttp/metrics
processors:
- resourcedetection/opentelemetry
- transform/identity
- batch/opentelemetry_metrics
receivers:
- forward/opentelemetry
On the upstream side of the same dump, postgresql/metrics_0 and postgresql/metrics_perresource_0 are lined up. count/dbi_dbload and signaltometrics/dbi_topsql also appear in this dump. DB load and top queries are assembled by the agent. The host metrics receiver is also configured by the agent without any settings.
The exit for metrics is otlphttp/metrics, and they are sent as OpenTelemetry metrics. For that reason, they do not appear in the list of conventional CloudWatch metrics that ListMetrics returns. Metrics in the CWAgent namespace were {"Metrics": []} when I checked after configuring and injecting load. Nothing equivalent to Database Insights appeared in the list of namespaces with active metrics in the last 3 hours either.
Retrieval via the Performance Insights API is also not possible. In the service model bundled with my AWS CLI 2.34.39, the pi API's ServiceType has only two values, RDS and DOCDB. There is no value for specifying self-managed PostgreSQL.
The access path for OTel metrics is PromQL. SQL text is not included in the metrics-side labels, so to follow things by query statement, look at the log group.
The collected metric names can be listed at https://monitoring.us-west-2.amazonaws.com/api/v1/label/__name__/values. The SigV4 service name is monitoring, and the required permissions are cloudwatch:GetMetricData and cloudwatch:ListMetrics. 69 metric names were returned, and I wrote none of them in the configuration file.
postgresql.active_sessions.by_app postgresql.active_sessions.by_db
postgresql.active_sessions.by_host postgresql.active_sessions.by_sql
postgresql.active_sessions.by_sql_wait postgresql.active_sessions.by_user
postgresql.active_sessions.by_wait postgresql.active_sessions.count
postgresql.backends postgresql.bgwriter.buffers.allocated
postgresql.bgwriter.buffers.writes postgresql.bgwriter.checkpoint.count
postgresql.bgwriter.duration postgresql.bgwriter.maxwritten
postgresql.blocks_read postgresql.calls postgresql.commits
postgresql.connection.max postgresql.database.count postgresql.db_size
postgresql.index.scans postgresql.index.size postgresql.operations
postgresql.rollbacks postgresql.rows postgresql.shared_blks_hit
postgresql.shared_blks_read postgresql.table.count postgresql.table.size
postgresql.table.vacuum.count postgresql.total_exec_time
postgresql.total_plan_time
process.cpu.time process.cpu.utilization process.disk.io
process.memory.usage process.memory.utilization process.memory.virtual
system.cpu.frequency system.cpu.load_average.15m system.cpu.load_average.1m
system.cpu.load_average.5m system.cpu.logical.count system.cpu.physical.count
system.cpu.time system.cpu.utilization system.disk.io system.disk.io_time
system.disk.merged system.disk.operation_time system.disk.operations
system.disk.pending_operations system.disk.weighted_io_time
system.filesystem.inodes.usage system.filesystem.usage
system.filesystem.utilization system.linux.memory.available
system.linux.memory.dirty system.memory.limit system.memory.page_size
system.memory.usage system.memory.utilization system.network.connections
system.network.dropped system.network.errors system.network.io
system.network.packets system.processes.count system.processes.created
Those starting with active_sessions correspond to the DB load in the console and its breakdown. The breakdown is by wait, SQL, database, user, application, and host. Those starting with system and process are host metrics.
The breakdown of DB load can be pulled with PromQL on the metrics side.
This is the breakdown for one minute during load injection (time=1788401100 = 2026-09-03T02:05:00Z).
sum by ("postgresql.wait_event_type","postgresql.wait_event") ({"postgresql.active_sessions.by_wait"})
| postgresql.wait_event_type | postgresql.wait_event | Value |
|---|---|---|
| CPU | CPU | 9 |
| Timeout | PgSleep | 2 |
| Lock | transactionid | 2 |
| LWLock | WALWrite | 1 |
| IPC | ExecuteGather | 1 |
| IPC | BtreePage | 1 |
| IO | WALSync | 1 |
The wait event types listed were the same as the console legend. The values are integers, and they did not match the AAS shown in the console (peak 2 to 3). Lining up the CPU wait trend with step=60 gives 4 → 7 → 8 → 10 → 7 → 7 → 8 → 6 → 9 → 9 → 9, one point per minute.
The OTLP structure is carried on the labels as is. The instance name, host type, and solution go in as they are. The label of the per-SQL metrics is the query ID.
I ran this query after deleting the stack and the log groups. The metrics remained, and they could be queried even though they do not appear in ListMetrics.
The first event arrived a few seconds after I loaded the configuration. The agent started at 01:53:35Z and the first event came at 01:53:37Z, and the two log groups appeared 32 seconds and 79 seconds after startup.
After the test, in addition to the stack, I also deleted the log groups that are outside CloudFormation's management.
aws cloudformation delete-stack --region us-west-2 --stack-name dbinsights-selfmanaged-pg
aws cloudformation wait stack-delete-complete --region us-west-2 --stack-name dbinsights-selfmanaged-pg
# Log groups are outside CloudFormation management, so delete them individually
aws logs delete-log-group --region us-west-2 \
--log-group-name /aws/self-managed-database-insights/postgresql/raw-events
aws logs delete-log-group --region us-west-2 \
--log-group-name /aws/self-managed-database-insights/postgresql/server-logs
Pricing
Database Insights for self-managed databases is charged based on the ingested volume. You pay for the OpenTelemetry metrics and CloudWatch Logs that the agent ingests. Self-managed databases do not use Standard mode or Advanced mode, so the vCPU-hour charges of RDS and Aurora do not apply.
On the pricing page at the time of writing, ingestion of OTel metrics is $0.50 per GB ingested. This flat rate includes storage for retaining the collected metrics for 15 months. There are no separate charges for API calls, metric storage, or the number of unique series.
Logs collected by the agent are stored in CloudWatch. The retention period follows the settings of the destination log group.
Summary
I was able to get a PostgreSQL that I operate myself on EC2 onto the Database Insights dashboard just by adding one section to the CloudWatch agent configuration. The mechanism uses the CloudWatch agent. The agent connects to the DB, reads the standard views, and sends the data to CloudWatch over OTLP. The breakdown that arrived could be obtained not only from the console dashboard but also from Logs Insights and PromQL.
Looking at the architecture, it is a combination of OTel receivers and generic batch forwarding, and only one receiver is engine-specific. PostgreSQL is the only self-managed engine supported right now, but the design leaves room for expansion to other engines. It is worth watching as a way to monitor DBs run on EC2 and the like. It is also useful as an example of using the CloudWatch agent and as a reference for OTel integration.
Database Insights on RDS and Aurora provides all of this mechanism as a managed service. With self-operation, you take on the PostgreSQL settings, the monitoring role, and the agent's constant cost yourself. For actual operations, I recommend leaning toward RDS or Aurora, and, as a chance to understand what is inside, running the agent on EC2 and trying it out.
