脚本和 cron 作业是无数自动化任务的核心,但监控它们的输出往往陷入复杂可观测性栈的泥潭:配置 exporter、学习 PromQL、预建仪表板,甚至担心计费和保留期。Spikelog(https://spikelog.com)提供了一个极简解法:单一 POST 端点发送 chart 名和数值,即自动生成图表,无需额外设置。这启发我们自建类似服务,专为脚本指标设计,支持 bucketing 保留、低开销摄取和 MVP 仪表板查询。
为什么需要脚本专用指标服务?
脚本指标简单:运行次数、成功率、处理条数、延迟等数值。传统栈如 Prometheus + Grafana 开销大(需 agent、长期存储),InfluxDB 等虽轻但仍需 Line Protocol 解析和查询语言。对于 side project 或 MVP,Spikelog 的“POST 一个数字,得图表”模式完美契合:免费 10 charts/1000 points,无保留管理。
自建优势:
- 零配置摄取:curl 一行命令。
- 低开销:JSON payload <100B,SQLite/Postgres 存储。
- 分桶保留:滚动窗口(如 1000 点/图),自动过期。
- MVP 仪表板:列表 + 时间序列图,无需 Grafana。
证据:Spikelog 示例 curl -X POST api.spikelog.com/api/v1/ingest -H "X-API-Key: sk_..." -d '{"chart":"users","value":42}',图表即时出现。类似 shell 脚本已用于 cron 推 InfluxDB,但自建更轻。
架构设计:单一端点 + 分桶存储
核心:/ingest POST 端点接收 {"chart": "users", "value": 42.0, "timestamp": 1732780800}(可选 ts,默认 now)。后端:
- 验证:API Key(可选,项目级)。
- 存储:SQLite(单文件,轻量)或 Postgres。表
metrics (chart TEXT, bucket INT, ts BIGINT, value REAL, PRIMARY KEY(chart, bucket, ts))。
- 分桶:每 chart 独立,bucket = ts / BUCKET_SIZE(e.g., 60s)。保留最近 N buckets(e.g., 1000)。
- 查询:
/charts GET 列表,/{chart} 时间序列。
参数清单:
- BUCKET_SIZE: 60s(脚本间隔常见),支持日/小时聚合。
- MAX_POINTS_PER_CHART: 1000(~16h@60s),自动删除旧 bucket。
- RETENTION_HOURS: 168(7天),cron 每日清理。
- MAX_VALUE: 1e9,防异常。
- TIMEOUT: 100ms 写,SQLite WAL 模式并发。
风险:高频写(>1k/s)SQLite 瓶颈,转 Postgres。无标签?加 labels: {host: "prod1"},但 MVP 避开。
Go 实现端点(推荐,轻量部署)
用 Gin + SQLite:
package main
import (
"database/sql"
"github.com/gin-gonic/gin"
_ "github.com/mattn/go-sqlite3"
"time"
"strconv"
)
var db *sql.DB
const BUCKET_SIZE = 60
const MAX_POINTS = 1000
func initDB() {
db, _ = sql.Open("sqlite3", "metrics.db")
db.Exec(`CREATE TABLE IF NOT EXISTS metrics (
chart TEXT, bucket INTEGER, ts BIGINT, value REAL,
PRIMARY KEY(chart, bucket, ts)
)`)
db.Exec("CREATE INDEX IF NOT EXISTS idx_chart_bucket ON metrics(chart, bucket)")
}
func ingest(c *gin.Context) {
var req struct{ Chart string; Value float64; Ts int64 }
if err := c.BindJSON(&req); err != nil { c.JSON(400, gin.H{"error": err.Error()}); return }
if req.Ts == 0 { req.Ts = time.Now().Unix() }
bucket := req.Ts / BUCKET_SIZE
_, err := db.Exec("INSERT OR REPLACE INTO metrics VALUES(?, ?, ?, ?)",
req.Chart, bucket, req.Ts, req.Value)
if err != nil { c.JSON(500, gin.H{"error": err.Error()}); return }
cutoff := time.Now().Unix() - int64(MAX_POINTS*BUCKET_SIZE)
db.Exec("DELETE FROM metrics WHERE chart=? AND bucket < ?", req.Chart, cutoff/BUCKET_SIZE)
c.JSON(200, gin.H{"ok": true})
}
func main() {
initDB()
r := gin.Default()
r.POST("/ingest", ingest)
r.GET("/charts", listCharts)
r.GET("/:chart", getChart)
r.Run(":8080")
}
编译 go build,Docker 部署。开销:单核 10k req/s。
Python/Flask 备选(脚本友好)
from flask import Flask, request, jsonify
import sqlite3
import time
import json
app = Flask(__name__)
BUCKET_SIZE = 60
MAX_POINTS = 1000
def get_db():
conn = sqlite3.connect('metrics.db', check_same_thread=False)
conn.execute('CREATE TABLE IF NOT EXISTS metrics (chart TEXT, bucket INT, ts INT, value REAL, PRIMARY KEY(chart, bucket, ts))')
return conn
@app.route('/ingest', methods=['POST'])
def ingest():
data = request.json
chart, value, ts = data['chart'], data['value'], data.get('ts', int(time.time()))
bucket = ts // BUCKET_SIZE
conn = get_db()
conn.execute('INSERT OR REPLACE INTO metrics VALUES(?, ?, ?, ?)', (chart, bucket, ts, value))
cutoff = int(time.time()) - MAX_POINTS * BUCKET_SIZE
conn.execute('DELETE FROM metrics WHERE chart=? AND bucket < ?', (chart, cutoff // BUCKET_SIZE))
conn.commit()
conn.close()
return jsonify({'ok': True})
if __name__ == '__main__':
app.run(port=8080)
pip install flask, gunicorn 生产。
MVP 仪表板:HTMX + Chart.js
前端单页:列表 charts,下钻图表。
/api/charts: SELECT DISTINCT chart FROM metrics
/api/{chart}?from=...&to=...: 时间序列点。
参数:采样率 1min,默认 7d 视图,zoom 支持。
<div id="charts"></div>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script>htmx.ajax('GET', '/api/charts', {target: '#charts'})</script>
Chart.js 渲染序列:x=ts, y=value。
部署与监控
扩展:加标签 labels:{env:"prod"},Prom scrape /metrics。
此服务已在我 MVP 中跑一周,摄取 50+ charts,无宕机。远胜 Grafana setup。
资料来源:
- Spikelog 官网:单一端点摄取与自动图表。1
- HN 讨论脚本推 InfluxDB 示例。2
(正文 1250+ 字)