diff --git a/apps/dashboard/src/scripts/README.md b/apps/dashboard/src/scripts/README.md new file mode 100644 index 00000000..f70b7e80 --- /dev/null +++ b/apps/dashboard/src/scripts/README.md @@ -0,0 +1,115 @@ +# Export Blog Post Metrics Script + +This script exports monitor metrics data from OpenStatus for use in blog posts and documentation. + +## Overview + +The script fetches monitor data directly from the database and Tinybird analytics, then exports it to a JSON file that can be used for visualizations in blog posts. + +**Features:** +- Fetches metrics from both regular regions and private locations +- Automatically combines public regions with private location data +- Supports both HTTP and TCP monitors + +## Configuration + +Edit the constants at the top of `export-blog-post-metrics.ts`: + +```typescript +const MONITOR_ID = "1"; // The ID of the monitor to export +const PERIOD = "7d"; // Time period: "1d", "7d", or "14d" +const INTERVAL = 60; // Interval in minutes for data points +const TYPE = "http"; // Fallback monitor type: "http" or "tcp" (auto-detected from monitor) +const OUTPUT_FILE = "blog-post-metrics.json"; // Output filename +``` + +**Note:** The script automatically detects the monitor type from the database, but you can set a fallback with the `TYPE` constant. + +## Prerequisites + +1. Make sure you have the `TINY_BIRD_API_KEY` environment variable set in your `.env` file +2. The database should be accessible (local or remote) +3. Install dependencies: `pnpm install` + +## Usage + +> [!IMPORTANT] +> Go to the `/tinybird/src/client.ts` file and make sure tb is **not using the NoopClient**. + +From the `apps/dashboard` directory: + +```bash +# Using the npm script +pnpm export-metrics + +# Or directly with bun +bun src/scripts/export-blog-post-metrics.ts +``` + +## Output Format + +The script generates a JSON file with the following structure: + +```json +{ + "regions": ["ams", "fra", "lhr", ...], + "data": { + "regions": ["ams", "fra", "lhr", ...], + "data": [ + { + "timestamp": "2025-08-18T16:00:00.000Z", + "ams": 207, + "fra": 142, + "lhr": 327, + ... + } + ] + }, + "metricsByRegions": [ + { + "region": "ams", + "count": 1000, + "ok": 995, + "p50Latency": 150, + "p75Latency": 200, + "p90Latency": 250, + "p95Latency": 300, + "p99Latency": 400 + } + ] +} +``` + +## Data Fields + +- **regions**: Array of region codes and private location names for the monitor +- **data.data**: Timeline data with latency values per region/location at each timestamp +- **metricsByRegions**: Summary statistics per region/location including: + - `count`: Total number of checks + - `ok`: Number of successful checks + - `p50Latency`, `p75Latency`, `p90Latency`, `p95Latency`, `p99Latency`: Latency percentiles in milliseconds + +**Note:** The script automatically includes both public Fly.io regions and any private locations connected to the monitor. + +## Example: Moving to Web Assets + +To use the exported data in the web app (like the existing `hono-cold.json`): + +```bash +# After running the script +cp blog-post-metrics.json ../web/public/assets/posts/your-blog-post/data.json +``` + +## Troubleshooting + +**Error: "TINY_BIRD_API_KEY environment variable is required"** +- Make sure you have the `TINY_BIRD_API_KEY` set in your `.env` file + +**Error: "Monitor with ID X not found"** +- Verify the monitor ID exists in your database +- Check that you're connected to the correct database + +**No data returned** +- Ensure the monitor has been running and collecting data for the specified period +- Try a different time period (e.g., "7d" instead of "1d") + diff --git a/apps/dashboard/src/scripts/export-blog-post-metrics.ts b/apps/dashboard/src/scripts/export-blog-post-metrics.ts new file mode 100644 index 00000000..023bcff2 --- /dev/null +++ b/apps/dashboard/src/scripts/export-blog-post-metrics.ts @@ -0,0 +1,226 @@ +import { writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { db, eq } from "@openstatus/db"; +import { monitor, selectMonitorSchema } from "@openstatus/db/src/schema"; +import { OSTinybird } from "@openstatus/tinybird"; + +// WARNING: make sure to enable the Tinybird client in the env you are running this script in + +// Configuration +const MONITOR_ID = "7002"; +const PERIOD = "7d" as const; +const INTERVAL = 60; +const TYPE = "http" as const; +const OUTPUT_FILE = "blog-post-metrics.json"; +const PERCENTILE = "p50"; // p50, p75, p90, p95, p99 + +async function main() { + // Get Tinybird API key from environment + const tinybirdApiKey = process.env.TINY_BIRD_API_KEY; + if (!tinybirdApiKey) { + throw new Error("TINY_BIRD_API_KEY environment variable is required"); + } + + const tb = new OSTinybird(tinybirdApiKey); + + console.log(`Fetching data for monitor ID: ${MONITOR_ID}`); + + // 1. Fetch monitor from database with private locations + const monitorDataRaw = await db.query.monitor.findFirst({ + where: eq(monitor.id, Number.parseInt(MONITOR_ID)), + with: { + privateLocationToMonitors: { + with: { + privateLocation: true, + }, + }, + }, + }); + + if (!monitorDataRaw) { + throw new Error(`Monitor with ID ${MONITOR_ID} not found`); + } + + // Parse the monitor data using the schema to convert regions string to array + const monitorData = selectMonitorSchema.parse(monitorDataRaw); + + // Get private location names + const privateLocationNames = + monitorDataRaw.privateLocationToMonitors + ?.map((pl) => pl.privateLocation?.name) + .filter((name): name is string => Boolean(name)) || []; + + // Combine regular regions with private locations + const allRegions = [...monitorData.regions, ...privateLocationNames]; + + console.log(`\nMonitor Details:`); + console.log(` ID: ${MONITOR_ID}`); + console.log(` Name: ${monitorData.name || "Unnamed"}`); + console.log(` Type: ${monitorData.jobType}`); + console.log(` Active: ${monitorData.active}`); + console.log(` Created: ${monitorData.createdAt}`); + console.log(` Regular regions: ${monitorData.regions.join(", ")}`); + console.log( + ` Private locations: ${privateLocationNames.join(", ") || "None"}` + ); + console.log(` Total regions: ${allRegions.length}`); + console.log(`\nQuery Parameters:`); + console.log(` Period: ${PERIOD}`); + console.log(` Interval: ${INTERVAL} minutes`); + + // Use the monitor's actual type, or fall back to the configured TYPE + const monitorType = (monitorData.jobType || TYPE) as "http" | "tcp"; + + // 2. Fetch metricsRegions (timeline data with region, timestamp, and quantiles) + const metricsRegionsResult = + monitorType === "http" + ? PERIOD === "7d" + ? await tb.httpMetricsRegionsWeekly({ + monitorId: MONITOR_ID, + interval: INTERVAL, + }) + : await tb.httpMetricsRegionsDaily({ + monitorId: MONITOR_ID, + interval: INTERVAL, + }) + : PERIOD === "7d" + ? await tb.tcpMetricsByIntervalWeekly({ + monitorId: MONITOR_ID, + interval: INTERVAL, + }) + : await tb.tcpMetricsByIntervalDaily({ + monitorId: MONITOR_ID, + interval: INTERVAL, + }); + + console.log( + `\nFetched ${metricsRegionsResult.data.length} metrics regions data points` + ); + if (metricsRegionsResult.data.length > 0) { + console.log( + ` First data point:`, + JSON.stringify(metricsRegionsResult.data[0], null, 2) + ); + console.log( + ` Last data point:`, + JSON.stringify( + metricsRegionsResult.data[metricsRegionsResult.data.length - 1], + null, + 2 + ) + ); + } else { + console.log(` āš ļø No data returned. This could mean:`); + console.log(` - The monitor hasn't collected any data yet`); + console.log(` - The monitor is inactive or was just created`); + console.log( + ` - There's no data in the selected time period (${PERIOD})` + ); + console.log( + `\n šŸ’” Tip: Try querying without the interval parameter or using PERIOD="1d"` + ); + + // Try without interval to see if that helps + console.log(`\n Trying without interval parameter...`); + const retryResult = + monitorType === "http" + ? PERIOD === "7d" + ? await tb.httpMetricsRegionsWeekly({ + monitorId: MONITOR_ID, + }) + : await tb.httpMetricsRegionsDaily({ + monitorId: MONITOR_ID, + }) + : PERIOD === "7d" + ? await tb.tcpMetricsByIntervalWeekly({ + monitorId: MONITOR_ID, + }) + : await tb.tcpMetricsByIntervalDaily({ + monitorId: MONITOR_ID, + }); + console.log(` Retry returned ${retryResult.data.length} data points`); + if (retryResult.data.length > 0) { + console.log(` āœ… Success! The interval parameter might be the issue.`); + console.log( + ` First data point:`, + JSON.stringify(retryResult.data[0], null, 2) + ); + } + } + + // 3. Fetch metricsByRegion (summary data by region) + const metricsByRegionProcedure = + monitorType === "http" + ? PERIOD === "7d" + ? tb.httpMetricsByRegionWeekly + : tb.httpMetricsByRegionDaily + : PERIOD === "7d" + ? tb.tcpMetricsByRegionWeekly + : tb.tcpMetricsByRegionDaily; + + const metricsByRegionsResult = await metricsByRegionProcedure({ + monitorId: MONITOR_ID, + }); + + console.log( + `\nFetched ${metricsByRegionsResult.data.length} metrics by region data points` + ); + if (metricsByRegionsResult.data.length > 0) { + console.log( + ` Sample:`, + JSON.stringify(metricsByRegionsResult.data.slice(0, 3), null, 2) + ); + } + + // 4. Transform metricsRegions data to match expected format + // Group by timestamp and pivot regions as columns + const timelineMap = new Map>(); + + for (const row of metricsRegionsResult.data) { + const timestamp = row.timestamp; + const region = row.region; + const latency = row[`${PERCENTILE}Latency`] ?? 0; + + if (!timelineMap.has(timestamp)) { + timelineMap.set(timestamp, { + timestamp: new Date(timestamp).toISOString(), + }); + } + + const entry = timelineMap.get(timestamp)!; + entry[region] = latency; + } + + // Convert map to sorted array + const timelineData = Array.from(timelineMap.values()).sort((a, b) => { + const timeA = new Date(a.timestamp as string).getTime(); + const timeB = new Date(b.timestamp as string).getTime(); + return timeA - timeB; + }); + + // 5. Build final output structure + const output = { + regions: allRegions, + data: { + regions: allRegions, + data: timelineData, + }, + metricsByRegions: metricsByRegionsResult.data, + }; + + // 6. Write to file + const outputPath = resolve(process.cwd(), OUTPUT_FILE); + writeFileSync(outputPath, JSON.stringify(output, null, 2)); + + console.log(`\nāœ… Data exported successfully to: ${outputPath}`); + console.log(`Total timeline entries: ${timelineData.length}`); + console.log( + `Total regions (including private locations): ${allRegions.length}` + ); +} + +// Run the script +main().catch((error) => { + console.error("Error:", error); + process.exit(1); +}); diff --git a/biome.jsonc b/biome.jsonc index 69d327fe..4d9a5a51 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -4,6 +4,7 @@ "ignore": [ "packages/ui/src/components/*.tsx", "packages/ui/src/components/*.ts", + "apps/dashboard/src/scripts/*.ts", ".devbox" ] },