Guide · AWS Timestream · Time-Series Monitoring
AWS Timestream for MCP Servers: Time-Series Uptime and Latency Metrics
AWS Timestream is a serverless time-series database that stores trillions of events per day with automatic tiering between fast in-memory storage and cost-efficient magnetic storage — making it a natural fit for MCP server health data where every 60-second probe produces a data point you want to query months later. Relational databases struggle with append-only time-series workloads at scale: indexes bloat, query plans degrade, and partition pruning requires careful schema design. Timestream is purpose-built for this pattern. This guide covers the Timestream data model, CDK database and table setup, a TypeScript write client for MCP probe results, and the SQL query patterns you need to compute uptime percentages and detect latency regressions. For deep-dive write patterns see Timestream WriteRecords for MCP server monitoring; for query patterns see Timestream SQL for MCP server uptime analysis.
TL;DR
Create a Timestream database and table via CDK with memoryStoreRetentionPeriodInHours: 24 and magneticStoreRetentionPeriodInDays: 93. Write probe results using TimestreamWriteClient with multi-measure records: one record per probe cycle carrying response_ms, status_code, is_up, and schema_checksum dimensions. Query 90-day uptime with ROUND(COUNT_IF(is_up = 'true') * 100.0 / COUNT(*), 2). Memory store holds the last 24 hours for fast dashboard queries; magnetic store holds 90 days for SLA reports at roughly $0.03/GB/month.
Why Timestream for MCP server health data
An MCP server monitoring system writes one record every 60 seconds per monitored endpoint. At 1,000 monitored endpoints that is 1,440,000 writes per day and roughly 130,000,000 records per quarter. A SQLite or PostgreSQL table can handle this with proper indexing, but you pay a maintenance cost: VACUUM schedules, partition management, and query plans that drift as the table grows.
Timestream removes that maintenance entirely. Records are automatically tiered: recent data stays in the memory store (configurable from 1 hour to 1 year) for sub-millisecond query latency, and older data is automatically moved to the magnetic store (up to 200 years) where it is compressed and indexed differently for range-scan queries. You never run VACUUM, reindex, or repartition.
The cost model also favors write-heavy monitoring workloads: writes are priced per million records written, reads per GB of data scanned. A monitoring system that writes 130M records per quarter but reads only a small window of recent data pays almost nothing in read costs because time-bounded queries scan very few GBs of magnetic storage.
The trade-offs:
- No UPDATE or DELETE in the memory store: Timestream is append-only. You cannot correct a record after writing it; you write a new record with the corrected values and query for the latest by timestamp. This is fine for probe results, which are naturally immutable.
- Schema is inferred at first write: The first record for a measure name determines the value type (BIGINT, DOUBLE, VARCHAR, BOOLEAN, TIMESTAMP). Subsequent records with a different type for the same measure name cause a
RejectedRecordsException. Decide your measure types before the first write and never change them. - Late-arrival records: Records timestamped before the memory store retention cutoff are rejected by default. If your probe worker crashes and resumes 25 hours later, a 24-hour memory-store retention drops those records. You can configure
MagneticStoreWritePropertiesto accept late arrivals into the magnetic store with an S3 error bucket for rejected records.
Data model: MCP probe records
Timestream records have three structural components: dimensions (categorical metadata that group records, stored as VARCHAR), measure names and values (the numeric or string data you are measuring), and a time field (the event timestamp, stored at nanosecond precision).
For MCP server monitoring, the following schema maps cleanly to Timestream's model:
-- Dimensions (categorical, low cardinality — appear in WHERE clauses)
server_slug VARCHAR -- e.g. "github-mcp", "stripe-mcp"
region VARCHAR -- e.g. "us-east-1" (probe origin region)
protocol VARCHAR -- "stdio" | "sse" | "http"
probe_version VARCHAR -- e.g. "v2" (your probe software version)
-- Multi-measure record values
response_ms BIGINT -- round-trip time in milliseconds
status_code BIGINT -- HTTP status code (0 if connection refused)
is_up VARCHAR -- "true" | "false" (Timestream has no BOOLEAN in all SDK versions)
schema_checksum VARCHAR -- SHA-256 of tools/list response (for drift detection)
error_class VARCHAR -- "" | "connection_refused" | "timeout" | "auth_error" | "json_error"
-- Time
check_time TIMESTAMP -- nanosecond-precision epoch when probe ran
A few design decisions worth noting:
is_upis stored as VARCHAR ("true"/"false") rather than BOOLEAN because multi-measure records use MULTI type which may require VARCHAR for boolean dimensions in older SDK versions. UseCOUNT_IF(is_up = 'true')in queries.schema_checksumbeing a dimension (not a measure) would allow filtering in the WHERE clause without scanning measures; keeping it as a measure value is fine if you only filter on it occasionally in post-processing.server_slugis a dimension, not a measure — it is high-usage in WHERE clauses and GROUP BY, which is exactly the Timestream dimension use case.
CDK setup: database, table, and retention
import * as timestream from 'aws-cdk-lib/aws-timestream';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as s3 from 'aws-cdk-lib/aws-s3';
import { Stack, StackProps, RemovalPolicy, Duration } from 'aws-cdk-lib';
import { Construct } from 'constructs';
export class McpTimestreamStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// Database: one per application, acts as a namespace
const db = new timestream.CfnDatabase(this, 'McpProbeDb', {
databaseName: 'mcp-probe-metrics',
});
// S3 bucket to capture magnetic-store write errors (late arrivals, schema conflicts)
const errorBucket = new s3.Bucket(this, 'TimestreamErrors', {
removalPolicy: RemovalPolicy.RETAIN,
lifecycleRules: [{ expiration: Duration.days(30) }],
});
// Table: one per metric type (or one shared table for all probe metrics)
const table = new timestream.CfnTable(this, 'McpProbeTable', {
databaseName: db.databaseName!,
tableName: 'probe-results',
retentionProperties: {
// Memory store: fast queries, sub-millisecond, priced at $0.036/GB/hr
memoryStoreRetentionPeriodInHours: 24,
// Magnetic store: 93 days covers a full quarter for SLA reporting
magneticStoreRetentionPeriodInDays: 93,
},
magneticStoreWriteProperties: {
// Accept late-arriving records (e.g. from a crashed probe worker that restarts after >24h)
enableMagneticStoreWrites: true,
magneticStoreRejectedDataLocation: {
s3Configuration: {
bucketName: errorBucket.bucketName,
objectKeyPrefix: 'rejected/',
encryptionOption: 'SSE_S3',
},
},
},
});
table.addDependency(db);
// IAM policy for the probe worker Lambda or ECS task
const writePolicy = new iam.ManagedPolicy(this, 'TimestreamWritePolicy', {
statements: [
new iam.PolicyStatement({
actions: [
'timestream:WriteRecords',
'timestream:DescribeEndpoints',
],
resources: [
`arn:aws:timestream:${this.region}:${this.account}:database/mcp-probe-metrics/table/probe-results`,
`arn:aws:timestream:${this.region}:${this.account}:database/mcp-probe-metrics`,
],
}),
],
});
// IAM policy for the dashboard / query Lambda
const readPolicy = new iam.ManagedPolicy(this, 'TimestreamReadPolicy', {
statements: [
new iam.PolicyStatement({
actions: [
'timestream:Select',
'timestream:DescribeTable',
'timestream:ListMeasures',
'timestream:DescribeEndpoints',
],
resources: ['*'],
}),
],
});
}
}
Key retention choices: memoryStoreRetentionPeriodInHours: 24 means the last 24 hours of data is available for fast (millisecond) queries, which covers real-time dashboards and alert evaluation. magneticStoreRetentionPeriodInDays: 93 covers a full calendar quarter — enough for monthly SLA reports and the 90-day uptime history shown on AliveMCP status pages.
enableMagneticStoreWrites: true is essential for fault-tolerant probe workers. Without it, any probe records timestamped before the memory store window are silently rejected. With it, late arrivals land in the magnetic store and rejected records (due to schema conflicts or other errors) are written to the S3 error bucket for inspection.
TypeScript write client: batch probe results
import {
TimestreamWriteClient,
WriteRecordsCommand,
type _Record,
type Dimension,
MeasureValueType,
TimeUnit,
} from '@aws-sdk/client-timestream-write';
const tsWrite = new TimestreamWriteClient({ region: process.env.AWS_REGION ?? 'us-east-1' });
const DATABASE = 'mcp-probe-metrics';
const TABLE = 'probe-results';
interface ProbeResult {
serverSlug: string;
region: string;
protocol: 'stdio' | 'sse' | 'http';
probeVersion: string;
responseMs: number;
statusCode: number;
isUp: boolean;
schemaChecksum: string;
errorClass: string;
checkTime: Date;
}
function toTimestreamRecord(result: ProbeResult): _Record {
const dimensions: Dimension[] = [
{ Name: 'server_slug', Value: result.serverSlug },
{ Name: 'region', Value: result.region },
{ Name: 'protocol', Value: result.protocol },
{ Name: 'probe_version', Value: result.probeVersion },
];
return {
Dimensions: dimensions,
MeasureName: 'probe',
MeasureValueType: MeasureValueType.MULTI,
MeasureValues: [
{ Name: 'response_ms', Value: String(result.responseMs), Type: MeasureValueType.BIGINT },
{ Name: 'status_code', Value: String(result.statusCode), Type: MeasureValueType.BIGINT },
{ Name: 'is_up', Value: String(result.isUp), Type: MeasureValueType.VARCHAR },
{ Name: 'schema_checksum', Value: result.schemaChecksum, Type: MeasureValueType.VARCHAR },
{ Name: 'error_class', Value: result.errorClass, Type: MeasureValueType.VARCHAR },
],
Time: String(result.checkTime.getTime()),
TimeUnit: TimeUnit.MILLISECONDS,
};
}
// Timestream WriteRecords accepts up to 100 records per call
const BATCH_SIZE = 100;
export async function writeProbeBatch(results: ProbeResult[]): Promise {
for (let i = 0; i < results.length; i += BATCH_SIZE) {
const batch = results.slice(i, i + BATCH_SIZE);
const records = batch.map(toTimestreamRecord);
try {
await tsWrite.send(new WriteRecordsCommand({
DatabaseName: DATABASE,
TableName: TABLE,
Records: records,
CommonAttributes: {
// CommonAttributes applies to all records in the batch — saves bandwidth
// for dimensions shared across the batch (e.g. same probe version)
},
}));
} catch (err: any) {
if (err.name === 'RejectedRecordsException') {
// RejectedRecords contains which records were rejected and why
const rejected = err.RejectedRecords ?? [];
for (const r of rejected) {
console.error(`Rejected record index ${r.RecordIndex}: ${r.Reason}`);
}
// Non-rejected records in the batch were still written successfully
} else if (err.name === 'ThrottlingException') {
// Timestream has per-account write limits; back off and retry
await new Promise(res => setTimeout(res, 1000 + Math.random() * 2000));
// In production, use a proper exponential backoff library
throw err;
} else {
throw err;
}
}
}
}
The MeasureValueType.MULTI type (multi-measure record) writes all probe metrics as a single record rather than one record per metric. This is the recommended Timestream write pattern for correlated data: it reduces write units consumed, keeps related data co-located in storage for efficient queries, and halves the number of rows returned by time-windowed queries.
Retention strategy: memory store vs. magnetic store
| Tier | Latency | Cost (approx) | Best for |
|---|---|---|---|
| Memory store (≤1 year) | <5 ms | ~$0.036/GB-hr | Real-time dashboards, alert evaluation, live status pages |
| Magnetic store (≤200 years) | 10–500 ms | ~$0.03/GB/month | Historical uptime reports, SLA PDFs, trend analysis, cost auditing |
For AliveMCP's public status pages, the 24-hour memory store window is sufficient for live green/red indicators and the rolling 7-day uptime summary. The 90-day uptime history shown on the Author and Team plan status pages is served from the magnetic store — queries are slower (tens to hundreds of milliseconds) but run infrequently enough that the latency is acceptable.
Increasing the memory store beyond 24 hours significantly increases cost: each hour of additional memory store retention adds to the GB-hr billing at ~$0.036/GB-hr. For 1,000 endpoints writing one record per minute, the table grows at roughly 1.4 million records per day. At approximately 200 bytes per multi-measure record, that is 280 MB/day or about 2 GB in 7 days. Seven days of memory store retention for that table would cost ~$0.036 × 2 GB × 168 hours ≈ $12/week in memory store alone. Stick to 24–48 hours of memory store and push historical queries to the magnetic store.
Timestream vs. other storage backends for MCP monitoring
| Backend | Write throughput | Query flexibility | Ops burden | Best fit |
|---|---|---|---|---|
| SQLite | ~10k writes/s (single process) | Full SQL, JOINs, indexes | None (embedded) | Single-machine MCP monitor up to ~500 endpoints |
| PostgreSQL / TimescaleDB | ~100k writes/s (tuned) | Full SQL + time_bucket(), COMPRESS | Medium — partitions, VACUUM, tune autovacuum | Self-hosted fleet monitoring requiring JOINs with application data |
| AWS Timestream | ~1M records/s (per table) | Timestream SQL (time_series, bin, ago, interpolate) | Zero — fully managed | Serverless monitoring at scale, automatic tiering, AWS-native stack |
| CloudWatch Custom Metrics | ~150 PutMetricData calls/s per account | CloudWatch Math only, no SQL | None (AWS managed) | AWS-native alerting integration; limited to ~1,500 custom metrics |
| InfluxDB Cloud | ~500k writes/s (free tier limited) | Flux language, PromQL compatible | Low — SaaS | Teams already invested in Grafana + PromQL ecosystem |
AliveMCP's current stack uses SQLite for its simplicity and zero-dependency deployment. Timestream becomes the right migration target when the monitored endpoint count exceeds a few thousand and you need the magnetic-store cost model for long-term retention without running disk-space management on a single VPS.
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
RejectedRecordsException on first write after schema change |
Adding a new measure value type to an existing measure name conflicts with the stored schema; Timestream infers schema from the first record and rejects future records with mismatched types | Use a new measure name (e.g. probe_v2) when adding incompatible measure fields; never change the type of an existing measure value |
| Records silently dropped after probe worker restart | Records timestamped before the memory store retention cutoff are rejected when enableMagneticStoreWrites is false |
Enable MagneticStoreWriteProperties.enableMagneticStoreWrites: true and configure an S3 error bucket to capture and inspect rejections |
ValidationException: The record timestamp is in the future |
System clock skew on the probe worker; Timestream rejects records timestamped more than 15 minutes in the future | Sync instance time with chronyc or the EC2 NTP service; use Date.now() not a manually constructed timestamp |
| Query returns no rows for a known endpoint | The dimension value uses different casing or spacing than stored (Timestream dimensions are case-sensitive) | Normalize server_slug to lowercase kebab-case before writing; use LOWER(server_slug) = 'github-mcp' in queries while migrating |
ThrottlingException on burst writes during re-probe after crash recovery |
Write limits: 1,000 records/s per table by default (soft limit, can be raised) | Buffer backlog writes and replay at a rate-limited pace (e.g. 800 records/s); request a Service Quotas increase if you routinely approach the limit |