[{"data":1,"prerenderedAt":181},["ShallowReactive",2],{"content:\u002Fen\u002Ffiddling\u002Fheart-rate-to-grafana":3,"surround:\u002Fen\u002Ffiddling\u002Fheart-rate-to-grafana":170},{"id":4,"title":5,"authorship":6,"body":7,"categories":138,"date":140,"description":141,"draft":142,"extension":143,"image":144,"meta":145,"navigation":147,"path":148,"permalink":149,"published":149,"readingTime":150,"recommend":149,"references":149,"seo":155,"seoDescription":156,"seoTitle":157,"sitemap":158,"stem":161,"tags":162,"type":168,"__hash__":169},"content_en\u002Fposts\u002Ffiddling\u002Fheart-rate-to-grafana.md","My Heart Beats for U: Visualizing Heart Rate in Grafana","human-only",{"type":8,"value":9,"toc":135},"minimark",[10,14,21,28,39,46,49,85,88,91,100,109,112,120,123,126,132],[11,12,13],"p",{},"A little side project: periodically sync my heart rate from Apple Health to a server and plot it in Grafana. It looks roughly like this:",[11,15,16],{},[17,18],"img",{"alt":19,"src":20},"","https:\u002F\u002Fblog-img.774352199.xyz\u002F2025\u002Fe01807e95f9c8ea4384d2c4d8f4fe3cb.png",[11,22,23,27],{},[24,25,26],"del",{},"Click the ♥️ in the top-right corner of the blog to view it. It runs through Cloudflare Tunnel, so access from China is slow; use a circumvention proxy 🪜 if possible."," Now offline: after switching to an OPPO smartphone, I can no longer sync and upload heart-rate data.",[11,29,30,31,38],{},"The idea is to use the REST API feature in ",[32,33,37],"a",{"href":34,"rel":35},"https:\u002F\u002Fapps.apple.com\u002Fus\u002Fapp\u002Fhealth-auto-export-json-csv\u002Fid1115567069?l=zh-Hans-CN",[36],"nofollow","Health Auto Export - JSON+CSV"," to send heart-rate data on a schedule to a deployed HTTP endpoint. That writes it to InfluxDB, and Grafana connects to InfluxDB to draw the dashboard.",[11,40,41,42,45],{},"Scheduled syncing in ",[32,43,37],{"href":34,"rel":44},[36]," requires Premium. A lifetime license costs USD 24.99 in the US store—a little steep, but I could not find a comparable cheaper alternative.",[11,47,48],{},"After subscribing, create an Automation:",[50,51,52,61,67,73,79,82],"ul",{},[53,54,55,56,60],"li",{},"Set Automation Type to ",[57,58,59],"code",{"code":59},"REST API",".",[53,62,63,64,60],{},"For URL, enter the address of the service deployed below. The API path is ",[57,65,66],{"code":66},"\u002Fpush\u002Fheart_rate",[53,68,69,70,60],{},"Set Data Type to ",[57,71,72],{"code":72},"Health Metrics",[53,74,75,76,60],{},"Under Select Health Metrics, check ",[57,77,78],{"code":78},"Heart Rate",[53,80,81],{},"Set Export Format to JSON.",[53,83,84],{},"Sync Cadence can be one or five minutes. Apple Watch does not measure heart rate continuously.",[11,86,87],{},"Check Enable. Add a home-screen widget to help keep syncing after the app is closed.",[11,89,90],{},"Next, deploy the service, exposing a REST API endpoint that receives data and writes it to InfluxDB. I will leave InfluxDB installation to Google; note that the service uses InfluxDB 2.",[11,92,93,94,99],{},"The source is at ",[32,95,98],{"href":96,"rel":97},"https:\u002F\u002Fgithub.com\u002Freekystive\u002Fhealthkit-collector",[36],"reekystive\u002Fhealthkit-collector",". It is a Node project that can run directly with pnpm, listening on port 3000. I wrote a Dockerfile to package it as a Docker image and deployed it on my home server.",[101,102,107],"pre",{"className":103,"code":105,"language":106,"meta":19},[104],"language-go","FROM node:20-alpine AS builder\n\n# Install pnpm\nRUN corepack enable && corepack prepare pnpm@9.14.2 --activate\n\n# Set working directory\nWORKDIR \u002Fapp\n\n# Copy package.json and pnpm-lock.yaml\nCOPY package.json pnpm-lock.yaml* .\u002F\n\n# Install dependencies\nRUN pnpm install --frozen-lockfile\n\n# Copy source code\nCOPY . .\n\n# Build the application\nRUN pnpm build\n\n# Stage 2: Production stage\nFROM node:20-alpine AS production\n\n# Install pnpm\nRUN corepack enable && corepack prepare pnpm@9.14.2 --activate\n\n# Set working directory\nWORKDIR \u002Fapp\n\n# Copy package.json and pnpm-lock.yaml\nCOPY package.json pnpm-lock.yaml* .\u002F\n\n# Install production dependencies only\nRUN pnpm install --prod --frozen-lockfile\n\n# Copy built application from builder stage\nCOPY --from=builder \u002Fapp\u002Fdist .\u002Fdist\n\n# Set environment variables\n# These are default values that can be overridden when running the container\nENV NODE_ENV=production\nENV PORT=3000\n\n# Expose the port your app runs on (using the PORT environment variable)\nEXPOSE ${PORT}\n\n# Command to run the application\nCMD [\"node\", \"dist\u002Findex.js\"]\n","go",[57,108,105],{"__ignoreMap":19},[11,110,111],{},"Set these four environment variables at startup for the InfluxDB connection.",[101,113,118],{"className":114,"code":116,"language":117},[115],"language-text","INFLUXDB_TOKEN='your_influxdb_token'\nINFLUXDB_URL='your_influxdb_url'\nINFLUXDB_ORG='your_influxdb_org'\nINFLUXDB_BUCKET='your_influxdb_bucket'\n","text",[57,119,116],{"__ignoreMap":19},[11,121,122],{},"Once deployed, try a sync. The service should log a successful database write.",[11,124,125],{},"Finally, deploy Grafana, add the data source, and create a dashboard with this query:",[101,127,130],{"className":128,"code":129,"language":117},[115],"from(bucket: \"bpm\")\n  |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n  |> filter(fn: (r) => r[\"_measurement\"] == \"heart_rate\")\n  |> filter(fn: (r) => r[\"_field\"] == \"avg\" or r[\"_field\"] == \"max\" or r[\"_field\"] == \"min\")\n",[57,131,129],{"__ignoreMap":19},[11,133,134],{},"Enjoy!",{"title":19,"searchDepth":136,"depth":136,"links":137},4,[],[139],"fiddling","2025-03-31 23:51:00","Periodically syncing heart-rate data from Apple Health to a server and visualizing it in Grafana provides an intuitive way to monitor it. Health Auto Export sends the data to an HTTP endpoint through its REST API automation, the server stores it in InfluxDB, and Grafana presents a clear dashboard for tracking and analyzing personal heart-rate changes.",false,"md","https:\u002F\u002Fblog-img.774352199.xyz\u002FF4qD2T.webp",{"slots":146},{},true,"\u002Ffiddling\u002Fheart-rate-to-grafana",null,{"text":151,"minutes":152,"time":153,"words":154},"3 min read",2.45,147000,490,{"title":5,"description":141},"Sync Apple Health heart-rate data via Health Auto Export to a self-hosted API, store it in InfluxDB 2, and build a Grafana dashboard with a sample query.","Streaming heart-rate data to Grafana: My heart beats for U",{"loc":148,"images":159},[160],{"loc":20},"posts\u002Ffiddling\u002Fheart-rate-to-grafana",[163,164,165,166,167],"Apple Watch","Health Auto Export","InfluxDB","Grafana","Heart rate monitoring","tech","WtWPQj9sRqQhgSI8z0VjVnpbCsh2d0agsUcSonvsHB4",[171,176],{"title":172,"path":173,"stem":174,"date":175,"type":168,"children":-1},"Resolving Type Name and Variable Name Ambiguity in Parsing","\u002Ffiddling\u002Fparser-type-variable-ambiguity","posts\u002Ffiddling\u002Fparser-type-variable-ambiguity","2025-03-15 20:35:00",{"title":177,"path":178,"stem":179,"date":180,"type":168,"children":-1},"Connecting My Shanghai and Hangzhou Networks","\u002Ffiddling\u002Fcross-city-network-setup","posts\u002Ffiddling\u002Fcross-city-network-setup","2025-04-18 16:43:12",1789914050362]