🏥

ヘルスケアデータをGrafanaで見たくない…?〜健康 Reliability Engineering〜

2024/01/17に公開

はじめに

まずはこちらをご覧ください。

これは私のApple Watchで計測されたヘルスケアデータです。Apple Watchをつけていると、心拍数や歩数、睡眠時間などのデータが自動的にiPhone内に記録されます。

SREなら健康を維持するためにもSLIとSLOを設定して可視化するべきですよね?

SREなら健康エラーバジェットが無くなりそうだったら「今すぐ寝ましょう!」と架電が来て欲しいですよね?

普通にやるとiOSアプリを用いて直接ヘルスケアデータを確認することになりますが、Web系のSRE的なエンジニアとしてはやはり業界標準の技術で可視化したいところです。

また、iOSアプリを開発するのは専門知識が必要となり非常に骨が折れる作業です。そもそもMacがないとできないですし。

そこで、今回は

  • Apple Watchのヘルスケアデータを
  • 全自動で良い感じにデータベースに保存し
  • Grafanaで可視化し
  • それをiPhoneのウィジェットとしてホーム画面に表示する

方法を紹介します。これにより、維持費無料全自動健康を可視化Reliability Engineeringできます。

維持費無料です! (重要)

構成

今回は以下のような構成になります。

  • Apple Watch
    • ヘルスケアデータを計測
    • iOSのヘルスケアデータが取れれば何でも良いです
  • Health Auto Export
    • iOSアプリ (サブスク or 買い切り)
    • ヘルスケアデータを自動で定期的に指定のURLにPOSTしてくれます (有料限定)
    • 買い切りすれば維持費無料!
  • Hono
    • ヘルスケアデータを受け取ってデータベースに保存するアプリケーションを実装するためのフレームワーク
    • 書きやすい!
  • Cloudflare Workers
    • Honoを動かすためのサーバレス環境
    • (今回の範囲では) 無料!
  • TiDB Serverless
    • MySQL互換のNewSQLデータベース
    • (今回の範囲では) 無料!
    • 無料なことが重要で、PlanetScaleでも良いと思います
  • Grafana Cloud
    • Grafanaのクラウドサービス
    • (今回の範囲では) 無料!
  • Widget Web
    • iOSアプリ (無料 or 買い切り)
    • ウェブサイトをiPhoneのホーム画面にウィジェットとして表示できるアプリ
    • 買い切りすれば維持費無料!

これだけ見るとわかると思いますが、実態はHonoでTiDBに突っ込んでいるだけです。

実装

テーブル定義

record_date以外はHealth Auto Exportで取得できるデータをそのまま突っ込んでいます。

アプリ側でどの項目をPOSTするかを設定できるのですが、自分は歩数と睡眠が欲しかったので他はおまけです。浮動小数点の精度も少数第2位までしか不要なのでそうしています。

自分の好きなようにデータを保持できることが最大のメリットなので、自分で欲しいデータを選んでください。


CREATE DATABASE HEALTHCARE;
USE HEALTHCARE;
CREATE TABLE `daily_health` (
  `record_date` DATE,
  `apple_stand_time` DECIMAL(8, 2),
  `basal_energy_burned` DECIMAL(8, 2),
  `walking_running_distance` DECIMAL(8, 2),
  `vo2_max` DECIMAL(8, 2),
  `apple_stand_hour` DECIMAL(8, 2),
  `active_energy` DECIMAL(8, 2),
  `resting_heart_rate` DECIMAL(8, 2),
  `walking_heart_rate_average` DECIMAL(8, 2),
  `heart_rate_variability` DECIMAL(8, 2),
  `apple_exercise_time` DECIMAL(8, 2),
  `walking_asymmetry_percentage` DECIMAL(8, 2),
  `walking_speed` DECIMAL(8, 2),
  `walking_step_length` DECIMAL(8, 2),
  `walking_double_support_percentage` DECIMAL(8, 2),
  `environmental_audio_exposure` DECIMAL(8, 2),
  `blood_oxygen_saturation` DECIMAL(8, 2),
  `sleep_analysis_in_bed` DECIMAL(8, 2),
  `sleep_analysis_rem` DECIMAL(8, 2),
  `sleep_analysis_deep` DECIMAL(8, 2),
  `sleep_analysis_awake` DECIMAL(8, 2),
  `sleep_analysis_core` DECIMAL(8, 2),
  `sleep_analysis_asleep` DECIMAL(8, 2),
  `respiratory_rate` DECIMAL(8, 2),
  `time_in_daylight` DECIMAL(8, 2),
  `physical_effort` DECIMAL(8, 2),
  `step_count` DECIMAL(8, 2),
  `heart_rate_min` DECIMAL(8, 2),
  `heart_rate_max` DECIMAL(8, 2),
  `heart_rate_avg` DECIMAL(8, 2),
  PRIMARY KEY (`record_date`)
);

Honoの実装

テストも書いてなくてリファクタリングもしてないので汚いですが、コードとしてはこれだけです。

内容としてはHealth Auto ExportからPOSTされたJSONをパースしてTiDBに突っ込んでいるだけですが、Honoを使うことで認証付きで簡単に実装できました。

ただし以下の注意点があります。

  • Cloudflare Workersの制限
    • 1回の処理あたり外部へのリクエストは50回までです。
      • したがって、SQLを50回しか発行できないので、大量のデータを一気に突っ込むことはできません。
      • 今回は前日のデータを毎日入れることで気になってはいませんが、過去のデータを入れたい時は別途実装する必要があります。
    • 1回の処理あたりのCPU使用時間は10msまでです。
      • 1日分のデータをパースするくらいであれば全く問題ありませんでした。
  • 認証
    • コードではBasic認証を使っていますが、JWTなどでも良いと思います。
    • Honoのドキュメントでは認証情報をベタ書きしていますが、下記コードのように環境変数から取得することもできるので必ずそうしましょう。
import { Hono } from 'hono'
import { connect } from '@tidbcloud/serverless'
import { basicAuth } from 'hono/basic-auth'

const app = new Hono()
type HealthcareData = {
  data: {
    metrics: Array<{
      name: string,
      data: Array<StandardMetric> | Array<HeartRateMetric> | Array<SleepAnalysisMetric>
    }>
  }
}

enum MetricType {
  HeartRateMetric = "heart_rate",
  SleepAnalysisMetric = "sleep_analysis",
}

type StandardMetric = {
  date: string,
  qty: number,
}

type HeartRateMetric = {
  date: string,
  Max: number,
  Min: number,
  Avg: number,
}

type SleepAnalysisMetric = {
  date: string,
  inBed: number,
  rem: number,
  deep: number,
  core: number,
  awake: number,
  asleep: number,
}

app.use("*", async (c, next) => {
  await basicAuth({
    username: c.env!.AUTH_USERNAME as string,
    password: c.env!.AUTH_PASSWORD as string,
  })(c, next)
});

app.post('/healthcare', async (c) => {
  const tidbUrl = c.env!.TIDB_URL as string
  const conn = connect({url: tidbUrl})
  const healthcareData = await c.req.json<HealthcareData>()
  for (const metric of healthcareData.data.metrics) {
    const date = metric.data[0].date.split(" ")[0]
    let query
    let args

    switch (metric.name) {
      case MetricType.HeartRateMetric: {
        const data = metric.data[0] as HeartRateMetric
        const max = data.Max
        const min = data.Min
        const avg = data.Avg
        query = `insert into daily_health (record_date, ${metric.name}_max, ${metric.name}_min, ${metric.name}_avg) values (?, ?, ?, ?) on duplicate key update ${metric.name}_max = ?, ${metric.name}_min = ?, ${metric.name}_avg = ?`
        args = [date, max, min, avg, max, min, avg]
        break
      }
      case MetricType.SleepAnalysisMetric: {
        const data = metric.data[0] as SleepAnalysisMetric
        const inBed = data.inBed
        const rem = data.rem
        const deep = data.deep
        const core = data.core
        const awake = data.awake
        const asleep = data.asleep
        query = `insert into daily_health (record_date, ${metric.name}_in_bed, ${metric.name}_rem, ${metric.name}_deep, ${metric.name}_core, ${metric.name}_awake, ${metric.name}_asleep) values (?, ?, ?, ?, ?, ?, ?) on duplicate key update ${metric.name}_in_bed = ?, ${metric.name}_rem = ?, ${metric.name}_deep = ?, ${metric.name}_core = ?, ${metric.name}_awake = ?, ${metric.name}_asleep = ?`
        args = [date, inBed, rem, deep, core, awake, asleep, inBed, rem, deep, core, awake, asleep]
        break
      }
      default: {
        const data = metric.data[0] as StandardMetric
        const value = data.qty
        query = `insert into daily_health (record_date, ${metric.name}) values (?, ?) on duplicate key update ${metric.name} = ?`
        args = [date, value, value]
        break
      }
    }
    try {
      await conn.execute(query, args)
    } catch (e) {
      console.log(e)
    }
  }
  return c.json({})
})

export default app

Grafana Cloudの設定

TiDB Serverlessからデータを取得できるようにData sourcesを設定する必要がありますが、ここでわかりづらいポイントが2点あります。

CA Cert

TiDB Serverlessに接続するにはCA Certを入力する必要がありますが、どこから取得すれば良いのかわかりづらいです。

TiDB Cloudの接続情報を取得する際、Operating SystemをWindowsにするとCA Certをダウンロードする選択肢が表示されるため、それをダウンロードして入力します。

TiDBのREAD限定ユーザの設定

Grafanaでは更新権限を持っているユーザでクエリすると危険なので、READ限定ユーザを作成してそれを使うようにします。

CREATE USER IF NOT EXISTS 'XXXXX.grafana';
SET PASSWORD FOR 'XXXXX.grafana' = 'PASSWORDなにかしら適当な文字列';

GRANT SELECT ON *.* TO 'XXXXX.grafana';

みたいにして作成し、このユーザで接続します。

TiDB Cloud上ではrootで接続する前提の情報しか出てきませんが、ユーザ名とパスワードを上記SQLで設定したものに変更すれば接続できます。

Grafanaのダッシュボードの作成

例えばこんなクエリで睡眠時間のパネルを描けます。

SELECT 
sleep_analysis_asleep,
sleep_analysis_rem,
sleep_analysis_deep,
sleep_analysis_core,
DATE_SUB(record_date, INTERVAL 9 HOUR) 
FROM HEALTHCARE.daily_health 
WHERE $__timeFilter(record_date)

自分だけのオリジナルダッシュボードを作りましょう!

まとめ

今回はApple Watchのヘルスケアデータを維持費無料で自動的にGrafanaで可視化する構成を紹介しました。

正直架電は冗談ですが、健康をSRE的な観点で可視化することで自分の健康状態を客観的に把握できるようになりますし、やろうと思えば架電まではいかずともPageとして通知はできるでしょう。

みなさんも是非オリジナルのデータを整備して、健康をReliability Engineeringしてみてください。

その他

クラウド事業者の素晴らしい理念のおかげでこういうおもちゃに無料枠を気軽に使えるのは本当に素晴らしいです。ありがとうございます。

参考

https://www.szdrblog.info/entry/2023/10/24/184638

https://note.com/_77dr/n/na916ef07aaf2

追記

軽くテスト実装もしてみました

import app from '.'

const mockExecute = jest.fn(() => ({
  rowCount: 1,
}))

jest.mock('@tidbcloud/serverless', () => ({
  connect: jest.fn(() => ({
    execute: mockExecute,
  })),
}))

const MOCK_ENV = {
  TIDB_URL: 'tidb.com',
  AUTH_USERNAME: "username",
  AUTH_PASSWORD: "password",
}

describe('テスト', () => {
  it('認証情報が適切な場合は200 OKが返ること', async () => {
    const res = await app.request('http://localhost/healthcare', {
      method: 'POST',
      headers: {
        Authorization: "Basic " + Buffer.from("username:password").toString("base64")
      },
      body: JSON.stringify({
        data: {
          metrics: [
            {
              name: 'heart_rate',
              data: [
                {
                  date: '2024-01-15 01:22:00 +0900',
                  Max: 100,
                  Min: 80,
                  Avg: 90,
                },
              ],
            },
          ],
        },
      }),
    }, MOCK_ENV)
    expect(res.status).toBe(200)
  })

  it('認証情報がない場合は401が返ること', async () => {
    const res = await app.request('http://localhost/healthcare', {
      method: 'POST',
      headers: {
        Authorization: "Basic " + Buffer.from("username:hoge").toString("base64")
      },
    }, MOCK_ENV)
    expect(res.status).toBe(401)
  })

  it('時刻が0時0分0秒になること', async () => {
    const res = await app.request('http://localhost/healthcare', {
      method: 'POST',
      headers: {
        Authorization: "Basic " + Buffer.from("username:password").toString("base64")
      },
      body: JSON.stringify({
        data: {
          metrics: [
            {
              name: 'time_in_daylight',
              data: [
                {
                  date: '2024-01-15 01:22:00 +0900',
                  qty: 100,
                }
              ],
            }
          ],
        }
      }),
    }, MOCK_ENV)
    expect(mockExecute).toHaveBeenCalledWith(
      "insert into daily_health (record_date, time_in_daylight) values (?, ?) on duplicate key update time_in_daylight = ?", 
      ['2024-01-15', 100, 100]
    )
    expect(res.status).toBe(200)
  })
})

Discussion