// Package hae — разбор тела доставки Health Auto Export в точки. // // Отдельно от хранения потому, что у разбора будет второй потребитель // (пересборка витрины из архива) и второй источник (родной экспорт Apple — // свой формат поверх того же хранилища). Пакет ничего не знает ни про SQLite, // ни про архив, и ничего не пишет: `Parse` — чистая функция от тела и // заголовков. // // Правила разбора выведены измерением живого потока, а не спроектированы: // docs/research/apple-health.md, находки 2, 30, 33, 35, 36, 38, 39, 47. Документация // HAE местами расходится с тем, что приложение шлёт на самом деле, поэтому // источник истины по формату — пакеты в testdata. package hae import ( "bytes" "encoding/json" "errors" "fmt" "sort" "time" "unicode/utf8" ) // Layer — подробность, в которой метрика приехала. Выводится из выравнивания // меток времени, а не из заголовка доставки: заголовок `Default` наблюдался // одновременно у посекундного, минутного и часового режимов (находка 33). type Layer string // Слои хранения. `sample` появится с импортом родного экспорта Apple, `day` // назначается схемам с фиксированной гранулярностью и не выводится. const ( LayerSample Layer = "sample" LayerRaw Layer = "raw" LayerMinute Layer = "minute" LayerHour Layer = "hour" LayerDay Layer = "day" ) // Ошибки разбора. var ( // ErrMalformed — тело не разбирается как JSON ожидаемой формы. ErrMalformed = errors.New("тело не разбирается") // ErrLayerUnknown — в доставке есть метрики, но определить их слой нечем: // плотных метрик нет, у автоматизации нет предыдущего надёжного слоя, а // заголовок ненадёжен. Точки не сохраняются, тело остаётся в архиве — // доставку подберёт пересборка, когда слой станет известен. // // Молчаливый выбор `raw` здесь недопустим: призрачный разрез поедет в // каталог и в правило Read API «самый мелкий слой, покрывающий диапазон». ErrLayerUnknown = errors.New("слой доставки не определяется") ) // Порог плотности: метрика, у которой не меньше стольких точек с метками, // классифицируется по собственному выравниванию. Редкая наследует слой // доставки — её собственное выравнивание ничего не значит, потому что одна // метка на часе бывает и у минутного ряда. const denseThreshold = 10 // timeLayout — формат метки точки в секции metrics. Другого там не // встречается: RFC 3339 живёт только в stateOfMind, а Unix-эпоха — внутри // heartbeatSeries, и меткой точки не является. const timeLayout = "2006-01-02 15:04:05 -0700" // Point — одна разобранная точка. type Point struct { // Metric — имя метрики как прислал HAE. Исключение — sleep_analysis: под // одним именем приезжают две несовместимые схемы, и они разводятся. Metric string Units string Layer Layer // Start и End — координаты точки в UTC. У точки-измерения End равен Start: // ключ одной формы для всех точек, потому что интервальная и точечная // формы не встречаются вперемешку внутри метрики одной доставки // (находка 47). Start time.Time End time.Time // OffsetSeconds — смещение исходной зоны. Нормализовать время без него // значит потерять, в каком часовом поясе человек находился. OffsetSeconds int // Raw — содержимое точки исходными байтами, как пришло в теле. Пересборка // повторной сериализацией теряет литерал (`1.0` → `1`, целые больше 2^53 // сдвигаются, невалидный UTF-8 → U+FFFD), и потеря не видна тестам на // фикстурах: они сравнивают разобранное с разобранным. Raw json.RawMessage // local — метка в исходной зоне. Наружу не отдаётся: хранится всегда UTC // плюс офсет. Нужна выводу слоя — HAE строит сетку по МЕСТНОМУ времени, и // выравнивание, посчитанное по UTC, объявляет часовую выгрузку минутной в // зонах с получасовым смещением (+0530, +0545, +0930). local time.Time } // Entity — сущность с собственным идентификатором: тренировка или запись // секции вроде stateOfMind. От точки отличается тем, что её адресует сам `id`, // а не координаты, и слоя у неё нет вовсе: подробности выгрузки у этих секций // в интерфейсе HAE не бывает. type Entity struct { // ID — идентификатор из HealthKit. Приходит из тела и ограничен по длине: // уезжает и в первичный ключ, и в записи лога. ID string // Kind — верхнеуровневый ключ секции HAE ДОСЛОВНО (`stateOfMind`, не // `state_of_mind`): инвариант «форма Apple не транслируется» относится и к // именам секций, а переименование после того, как значение легло в базу, // стоило бы миграции данных. У тренировки род один и в ключ не входит. Kind string // Name — имя тренировки как прислал HAE, локализованное («В помещении // Ходьба»). У записей пустое. Name string // Start и End — координаты в UTC. Конец, которого нет или который не // читается, равен началу: ключ сущности — `id`, схлопывать нечего, а истина // остаётся в Raw. У точки то же вырождение запрещено — там оно схлопнуло бы // две записи в одну координату. Start time.Time End time.Time // OffsetSeconds — смещение зоны НАЧАЛА. Колонка одна, а тренировка через // смену зоны дала бы два разных. OffsetSeconds int // Duration — длительность тренировки в секундах, как прислал HAE. Не // вычисляется из интервала: HAE шлёт 91.746 при интервале в 91 секунду. // Отсутствие выражается nil, а не нулём: ноль — законная длительность. Duration *float64 // Raw — содержимое сущности исходными байтами, как пришло в теле, включая // маршрут и внутренние ряды. Raw json.RawMessage } // Result — итог разбора доставки. Частичные исходы живут в счётчиках, а не в // ошибке: пакет, у которого не разобралась одна точка из тысячи, — обычное // дело, и терять из-за неё остальное нельзя. type Result struct { Points []Point // Workouts и Records — сущности с собственным идентификатором. Разведены, // потому что у тренировки есть заголовок (имя, интервал, длительность), по // которому идёт выборка, а у записи его нет. Workouts []Entity Records []Entity // SkippedNoID — сущности без пригодного идентификатора: пустого, нет вовсе // или длиннее предела. Один счётчик на все три случая: исход у них общий, а // различает их только тело, лежащее в архиве. SkippedNoID int // SkippedEntityNoTime — сущности с идентификатором, но без разбираемой // метки времени. SkippedEntityNoTime int // SkippedEntityMalformed — элементы секции, не разобравшиеся как объект. SkippedEntityMalformed int // Uncovered — верхнеуровневые ключи `data`, которых разбор не покрывает, // отсортированные и без повторов. Половина живого потока состоит из таких // доставок целиком (48 из 99: workouts и stateOfMind), и без этого списка // они неотличимы от разобранной доставки с пустой секцией метрик. // // Список канонизирован потому, что уезжает в базу и сравнивается между // доставками, а порядок ключей в JSON от HAE нестабилен. Uncovered []string // UncoveredDropped — сколько имён отброшено границей списка. Молчаливое // усечение сделало бы список уверенным, но неполным ответом на вопрос «что // останется потерянным, если тело удалить». UncoveredDropped int // Metrics — сколько метрик встретилось в секции. Metrics int // SkippedNoTime — точки без разбираемой метки времени. SkippedNoTime int // SkippedMalformed — точки, не разобравшиеся как объект JSON. SkippedMalformed int // SkippedBadEnd — точки, у которых есть `end`, но он не разбирается. // Вырождать такую точку в мгновенную нельзя: она схлопнулась бы с соседней // по координате. SkippedBadEnd int // Categoricals — различные наблюдённые категориальные значения доставки с // выведенными кодами, в детерминированном порядке. Повтор одной строки в // тысяче точек даёт один элемент. Categoricals []Categorical // CategoricalUnknown — сколько РАЗЛИЧНЫХ наблюдений не получило кода. // Считаются различные значения, а не их вхождения: счётчик отвечает на // вопрос «сколько строк ждёт словаря», а не «сколько точек их несло». // // В установившемся режиме он ненулевой — словарь покрывает только фазы сна, // а контекст пульса и имена тренировок объявлены категориальными заранее. // Сигналом «появилось новое» служит поэтому новая строка реестра, а не // ненулевой счётчик. CategoricalUnknown int // CategoricalDropped — сколько ВХОЖДЕНИЙ отброшено границами: непомерная // длина значения или переполнение числа различных значений. // // Единица названа вслух и отличается от соседнего счётчика намеренно. // Считать здесь различные значения нечем: набор ограничен при вставке (иначе // накопитель растёт вместе с телом, а тело контролирует отправитель), и // отброшенный ключ нигде не запоминается — запомнить его значило бы вернуть // ровно тот неограниченный рост, ради устранения которого граница и стоит на // вставке. Поэтому счётчик отвечает на вопрос «сколько раз сработала // граница», а не «сколько строк потеряно»; на второй отвечает реестр в // витрине. CategoricalDropped int // Layer — слой, выведенный для доставки в целом (тот, что наследуют редкие // метрики). Пустой, если плотных метрик не было и наследовать было нечего. // Его сохраняет вызывающий, чтобы следующая доставка той же автоматизации // могла его унаследовать. Layer Layer // LayerMismatch — выведенный слой разошёлся с НАДЁЖНЫМ заголовком. // Заголовок `Default` в сравнении не участвует: он не означает режима, и // сравнение с ним давало бы WARN на каждой доставке потока в пять минут. LayerMismatch bool // HeaderLayer — слой по заголовку, если заголовок надёжен. HeaderLayer Layer } // Meta — что доставка рассказала о себе заголовками, плюс память о прошлых // доставках той же автоматизации. type Meta struct { // Aggregation — заголовок `automation-aggregation`. Надёжен только в // значениях `Minutes` и `Hours`. Aggregation string // FallbackLayer — последний надёжно выведенный слой этой же автоматизации // (`automation-id`). Нужен доставкам без плотных метрик: измерено 2 такие // из 89, обе с заголовком `Default`. Ищет и передаёт его вызывающий — // разбор остаётся чистой функцией. FallbackLayer Layer // Locale — нормализованный языковой тег доставки из `Accept-Language` // (находка 32). Сужает поиск по словарю категориальных значений и НИКУДА не // сохраняется: заголовков в сыром архиве нет, поэтому доставка, // восстановленная из осиротевшего тела, приезжает без локали — и обязана // дать то же состояние. Пустая локаль законна. Locale string } // Parse разбирает секцию metrics тела доставки в точки. // // Ошибка возвращается только когда точек не будет вовсе: тело не JSON // (ErrMalformed) или слой не определяется (ErrLayerUnknown). Всё остальное — // счётчики в Result. Отсутствие секции metrics ошибкой не является: доставки // с одними тренировками или состоянием разума — норма. func Parse(body []byte, meta Meta) (res Result, err error) { // Разбор чужого формата обязан отвечать ошибкой, а не паникой: приём не // имеет права упасть из-за того, что HAE прислал невиданное. Перехват // стоит здесь, внутри разбора, а не выше: паника из хранилища — настоящий // дефект, и глушить её нельзя. defer func() { if r := recover(); r != nil { res = Result{} err = fmt.Errorf("%w: паника разбора: %s", ErrMalformed, clip(fmt.Sprint(r))) } }() env, err := decodeEnvelope(body) if err != nil { return Result{}, err } res.Uncovered = env.uncovered res.UncoveredDropped = env.dropped cat := newCategoricals(meta.Locale) res.Workouts = decodeEntities(env.workouts, workoutsSection, &res) res.Records = decodeEntities(env.stateOfMind, stateOfMindSection, &res) // Имя тренировки берётся из уже разобранного заголовка сущности. Мягкое // чтение превратило значение не того типа в пустую строку, а пустая строка // наблюдением не считается, — то есть «имени не было» и «имя приехало // числом» дают один исход, и он верный. for _, w := range res.Workouts { cat.add(workoutsSection, fieldName, w.Name) } metrics := env.metrics res.Metrics = len(metrics) if len(metrics) == 0 { res.Categoricals, res.CategoricalUnknown, res.CategoricalDropped = cat.result() return res, nil } groups := make([]group, 0, len(metrics)) for _, m := range metrics { g := decodeGroup(m, &res, cat) if len(g.summaries) > 0 { groups = append(groups, group{ metric: sleepSummaryMetric, units: g.units, points: g.summaries, layer: LayerDay, fixed: true, }) g.summaries = nil } if len(g.points) > 0 { groups = append(groups, g) } } if len(groups) == 0 { res.Categoricals, res.CategoricalUnknown, res.CategoricalDropped = cat.result() return res, nil } res.HeaderLayer = headerLayer(meta.Aggregation) if err := assignLayers(groups, meta, &res); err != nil { // Список непокрытых секций переживает отказ: доставка, у которой не // определился слой, обязана остаться записью о том, что в теле есть // невосстановимая секция. Иначе ретеншен увидит failed без списка и // решит, что терять нечего. // // Сущности при этом НЕ отдаются, хотя слоя у них нет и разобрались они // успешно. «Всё или ничего» относится к доставке, а не к точкам: отдай // мы их, доставка получила бы `failed` при частично записанной витрине, // и повторная свёртка перестала бы быть no-op. Цена названа в спеке — // такая доставка доедет пересборкой, а тело ждёт в архиве. return Result{ Metrics: res.Metrics, Uncovered: res.Uncovered, UncoveredDropped: res.UncoveredDropped, }, err } // Наблюдения отдаются только на успешном исходе: «всё или ничего» относится // к доставке целиком. У отказа по слою (см. ветку выше) сущности не // отдаются по той же причине. res.Categoricals, res.CategoricalUnknown, res.CategoricalDropped = cat.result() total := 0 for _, g := range groups { total += len(g.points) } res.Points = make([]Point, 0, total) for _, g := range groups { for _, p := range g.points { p.Layer = g.layer res.Points = append(res.Points, p) } } return res, nil } // group — точки одной метрики одной доставки: единица, для которой выводится // слой. Классификация именно по метрике, а не по доставке: при перенастройке // автоматизации приезжают смешанные доставки, и отнесение такой доставки к // одному слою складывает минутные точки с посекундными (находка 33). type group struct { metric string units string points []Point // layer — итоговый слой группы. layer Layer // fixed — слой назначен схемой, а не выведен (суточная сводка сна). Такие // группы не участвуют в определении слоя доставки: сводок бывает больше // порога плотности, и их полуночные метки назначили бы всей доставке hour. fixed bool // alignment — самое мелкое выравнивание среди меток группы. alignment Layer dense bool // summaries — точки, схема которых опознана прямо при разборе и слой // которым назначен, а не выведен (суточная сводка сна). Держатся отдельно, // потому что в определении слоя доставки не участвуют: сводок бывает больше // порога плотности, и их полуночные метки назначили бы всей доставке `hour`. summaries []Point } // envelope — форма тела, ровно настолько подробная, насколько нужно разбору. // // Точки держатся сырыми сообщениями и декодируются по одной: разбор тела в // 42 МиБ через map[string]any удерживает 197 МиБ кучи против 54 МиБ у этой // формы. Вместе с самим телом пик доходил бы до ~300 МиБ на доставку — это // OOM ровно на пике потока, когда терять доставки дороже всего. // Секции, которые разбор покрывает. Прочие секции с собственными `id` (`ecg`, // `symptoms`, `cycleTracking`, `medications`, `heartRateNotifications`) // покрытыми намеренно не становятся: живой поток не приносил их ни разу, их // форма никем не наблюдалась, а полнота покрытия HealthKit ради полноты целью // проекта не является. const ( metricsSection = "metrics" workoutsSection = "workouts" stateOfMindSection = "stateOfMind" ) // decodeCovered разбирает секцию, если разбор её покрывает; второй возврат // говорит, взялся ли он за неё. // // Один источник и для разбора, и для перечисления непокрытых: перечисляющий // спрашивает ровно того, кто разбирает, поэтому состояние «секция разбирается, // но числится непокрытой» невыразимо по построению. Отдельный предикат // `covered` разошёлся бы с этим switch при первой же новой секции. // // Повтор ключа покрытой секции JSON допускает; секции ОБЪЕДИНЯЮТСЯ, а не // побеждает последняя: терять данные молча нельзя. func decodeCovered(name string, dec *json.Decoder, env *envelope) (bool, error) { switch name { case metricsSection: var part []metricEnvelope if err := dec.Decode(&part); err != nil { return true, err } env.metrics = append(env.metrics, part...) return true, nil case workoutsSection: part, err := decodeSection(dec) if err != nil { return true, err } env.workouts = append(env.workouts, part...) return true, nil case stateOfMindSection: part, err := decodeSection(dec) if err != nil { return true, err } env.stateOfMind = append(env.stateOfMind, part...) return true, nil default: return false, nil } } // Границы на список непокрытых ключей. Тело контролирует отправитель целиком: // без границ сто тысяч однобуквенных ключей превращаются в одну строку в базе // и одну строку в логе того же порядка. Секций у HAE восемь, самое длинное имя // — heartRateNotifications (22 байта), так что запас велик. const ( maxUncovered = 32 maxUncoveredLen = 64 ) type metricEnvelope struct { Name string `json:"name"` Units string `json:"units"` Data []json.RawMessage `json:"data"` } // pointHead — поля точки, нужные разбору. Всё остальное остаётся в Raw и // хранится дословно: «служебных» полей у точки нет, отбрасывать нечего. type pointHead struct { Date string `json:"date"` Start string `json:"start"` End string `json:"end"` // TotalSleep различает две схемы под именем sleep_analysis: поэпизодную и // суточную сводку. Общих полей, кроме date и source, у них нет. TotalSleep *json.RawMessage `json:"totalSleep"` // Value и Context — категориальные поля точки (фаза сна и контекст пульса, // находка 37). Сырыми сообщениями, а не строками: объяви их `string`, и // точка, у которой поле пришло числом, перестала бы разбираться вовсе — // json.Unmarshal отвечает ошибкой на несовпадение типа, а decodeGroup // считает такую точку не разобравшейся. Новый путь потери точки ради // удобства структуры недопустим. Value *json.RawMessage `json:"value"` Context *json.RawMessage `json:"context"` } // decodeEnvelope разбирает конверт: отдаёт секцию metrics и имена секций, // которых разбор не покрывает. // // Идёт по верхнему уровню одним декодером: Token() читает рамку объекта и имена // членов, Decode() — значения. Значение покрытого ключа декодируется на месте, // значение непокрытого ПРОГЛАТЫВАЕТСЯ декодированием в выбрасываемый // RawMessage. Это форма из ExampleDecoder_Decode_stream стандартной библиотеки; // в encoding/json/v2 та же операция названа прямо — SkipValue. // // Пропуск ручным счётом глубины по Token() выглядит дешевле и измеримо хуже: // делимитеры идут мимо сканера, поэтому ограничитель вложенности encoding/json // не работает, а стек токенов растёт как O(глубины). Тело 40 МиБ из вложенных // скобок даёт пик 488 МиБ вместо контрактных четырёх тел — Decode отвергает его // мгновенно. Разбор `data` в map[string]json.RawMessage дешевле по коду, но // копирует байты ВСЕХ секций и держит их до конца разбора; у проглатывания // копия одна и живёт до следующего члена. func decodeEnvelope(body []byte) (envelope, error) { var env envelope fail := func(e error) (envelope, error) { return envelope{}, fmt.Errorf("%w: %s", ErrMalformed, ClipCause(e)) } dec := json.NewDecoder(bytes.NewReader(body)) // Верхний уровень тела: интересует только data. Прочие ключи конверта в // список не идут — иначе в одном списке смешались бы имена секций и мусор // конверта, а форму `{"data": …}` проверяет приём. at := dec.InputOffset() tok, err := dec.Token() if err != nil { return fail(err) } // Голый null телом ошибкой не был и не становится: прежний разбор // раскладывал его в пустую структуру. Границы поведения этой задачей не // двигаются — она добавляет список, а не строгость. if tok == nil { return envelope{}, nil } if d, ok := tok.(json.Delim); !ok || d != '{' { return fail(fmt.Errorf("ожидался объект, встречено %s", tokenDesc(tok, at))) } seen := make(map[string]struct{}) for dec.More() { name, err := memberName(dec) if err != nil { return fail(err) } if name != "data" { if err := swallow(dec); err != nil { return fail(err) } continue } // Повтор самого члена `data` JSON допускает, и результаты // НАКАПЛИВАЮТСЯ, а не замещаются: присваивание теряло бы секции первого // члена целиком, причём молча — их имена уже отмечены в `seen` и во // второй список непокрытых не попали бы. Правило то же, что уровнем // ниже для повтора ключа секции. if err := decodeData(dec, seen, &env); err != nil { return fail(err) } } if err := expectDelim(dec, '}'); err != nil { return fail(err) } // Список канонизируется: порядок ключей в JSON от HAE нестабилен, а // значение уезжает в базу и сравнивается между доставками. sort.Strings(env.uncovered) return env, nil } // envelope — что разбор вынул из тела: покрытые секции и имена непокрытых. type envelope struct { metrics []metricEnvelope workouts []json.RawMessage stateOfMind []json.RawMessage uncovered []string dropped int } // decodeData разбирает объект data, дописывая в конверт покрытые секции и // имена непокрытых. func decodeData(dec *json.Decoder, seen map[string]struct{}, env *envelope) error { at := dec.InputOffset() tok, err := dec.Token() if err != nil { return err } // data не объект — прежнее поведение: ошибка ровно там, где была. if d, ok := tok.(json.Delim); !ok || d != '{' { return fmt.Errorf("data: ожидался объект, встречено %s", tokenDesc(tok, at)) } for dec.More() { name, err := memberName(dec) if err != nil { return err } handled, err := decodeCovered(name, dec, env) if err != nil { return err } if handled { continue } if err := swallow(dec); err != nil { return err } if _, dup := seen[name]; dup { continue } seen[name] = struct{}{} if len(env.uncovered) >= maxUncovered { env.dropped++ continue } env.uncovered = append(env.uncovered, clipSection(name)) } if _, err := dec.Token(); err != nil { // закрывающая скобка data return err } return nil } // decodeSection читает секцию сущностей элементами исходных байтов. // // Разбор до `json.RawMessage`, а не до структуры: сущность хранится дословно, и // декодирование в типизированное значение потеряло бы литерал — ровно то, от // чего защищает `Point.Raw`. func decodeSection(dec *json.Decoder) ([]json.RawMessage, error) { var part []json.RawMessage if err := dec.Decode(&part); err != nil { return nil, err } return part, nil } // memberName читает имя члена объекта. Token() отдаёт имя уже после разбора // escape-последовательностей, поэтому границы считаются по декодированному. func memberName(dec *json.Decoder) (string, error) { at := dec.InputOffset() tok, err := dec.Token() if err != nil { return "", err } name, ok := tok.(string) if !ok { return "", fmt.Errorf("ожидалось имя члена, встречено %s", tokenDesc(tok, at)) } return name, nil } // maxCauseLen — предел длины чужой причины в тексте нашей ошибки. // // Сообщения самого разбора значений не несут (см. tokenDesc), но ошибка может // прийти и из encoding/json, а его UnmarshalTypeError кладёт в текст ЛИТЕРАЛ // значения: тело из миллиона цифр давало текст ошибки в мегабайт, и он уезжал // атрибутом `error` выше DEBUG. Предел держится здесь, на границе, а не у // логирующего: обрезка живёт в другом месте и о новой ошибке разбора не узнает. const maxCauseLen = 200 // ClipCause переводит чужую ошибку в ограниченную по длине строку. // // Экспортировано ради приёма: он проверяет форму конверта тем же // encoding/json и обязан держать тот же предел — иначе инвариант обходится // через соседний пакет. func ClipCause(err error) string { if err == nil { return "" } return clip(err.Error()) } func clip(s string) string { if len(s) <= maxCauseLen { return s } // По границе рун: обрезка посреди многобайтовой руны даёт мусор в логе. cut := maxCauseLen for cut > 0 && !utf8.RuneStart(s[cut]) { cut-- } return s[:cut] + "…" } // tokenDesc описывает встреченный токен БЕЗ его значения: род и смещение начала // во входе. // // Значение из тела в сообщение не попадает никогда. Инвариант «тела запросов // только на DEBUG и с обрезкой» обходится одним `%v`: строка в 8 МиБ на месте // ожидаемого объекта давала текст ошибки в 8 МиБ, и он уезжал атрибутом `error` // на уровень WARN — то есть содержимое доставки оказывалось в логе целиком. // Предел держит само сообщение, а не обрезка на стороне логирующего: обрезка // живёт в другом месте и о новой ошибке разбора не узнает. // // Род называется словарём JSON, а не именем типа языка: `json.Delim` не говорит // ничего о том, какая скобка встретилась. Сам делимитер печатается значением — // он из фиксированного набора и содержимого не раскрывает. // // Смещение берётся ДО чтения токена: InputOffset() отдаёт позицию конца // последнего возвращённого токена, и снятое после оно указывало бы на конец // виновного значения — то есть на восемь мегабайт дальше начала проблемы. func tokenDesc(tok json.Token, at int64) string { kind := "?" switch v := tok.(type) { case json.Delim: kind = fmt.Sprintf("%q", string(v)) case string: kind = "string" case json.Number, float64: kind = "number" case bool: kind = "bool" case nil: kind = "null" } return fmt.Sprintf("%s на смещении %d", kind, at) } // swallow проглатывает значение целиком, ничего не удерживая. func swallow(dec *json.Decoder) error { var skip json.RawMessage return dec.Decode(&skip) } func expectDelim(dec *json.Decoder, want json.Delim) error { at := dec.InputOffset() tok, err := dec.Token() if err != nil { return err } if d, ok := tok.(json.Delim); !ok || d != want { return fmt.Errorf("ожидалось %q, встречено %s", want, tokenDesc(tok, at)) } return nil } // clipSection обрезает слишком длинное имя по границе рун и помечает обрезку. // Маркер приписывается СВЕРХ предела: обрезка не инъективна, и обрезанное имя // сравнению со словарём известных секций не подлежит. func clipSection(name string) string { if len(name) <= maxUncoveredLen { return name } cut := maxUncoveredLen for cut > 0 && !utf8.RuneStart(name[cut]) { cut-- } return name[:cut] + "…" } // decodeGroup разбирает точки одной метрики. Точка без разбираемой метки // пропускается со счётчиком — ронять из-за неё остальную доставку незачем. // // Схема точки определяется ЗДЕСЬ ЖЕ, в том же проходе, где точка разобрана. // Второй проход по исходному массиву был бы неверен: список разобранных точек // уже отфильтрован пропусками, и любой пропуск сдвигал бы соответствие — эпизод // сна уезжал бы под имя суточной сводки, а сводка под имя эпизода. Индексной // корреляции между двумя списками здесь не существует по построению. func decodeGroup(m metricEnvelope, res *Result, cat *categoricals) group { g := group{metric: m.Name, units: m.Units, points: make([]Point, 0, len(m.Data))} for _, raw := range m.Data { var head pointHead if err := json.Unmarshal(raw, &head); err != nil { res.SkippedMalformed++ continue } start, ok := parseTime(head.Date, head.Start) if !ok { res.SkippedNoTime++ continue } end := start if head.End != "" { e, err := time.Parse(timeLayout, head.End) if err != nil { // Интервал, конец которого не читается, вырождать в точку // нельзя: две записи с общим началом получили бы одну // координату и одна из них исчезла бы. Пропускаем со // счётчиком — тело остаётся в архиве. res.SkippedBadEnd++ continue } end = e } _, offset := start.Zone() p := Point{ Metric: m.Name, Units: m.Units, Start: start.UTC(), End: end.UTC(), OffsetSeconds: offset, Raw: raw, local: start, } // Под именем sleep_analysis HAE шлёт две несовместимые схемы: // поэпизодную (start/end/value/qty) и суточную сводку // (totalSleep/core/rem/deep/awake с меткой на местной полуночи). Имя // sleep_analysis_summary — наше; инвариант «форма Apple не // транслируется» это не нарушает: поля внутри точки не // переименовываются, разделяются только имена метрик, под которыми // HAE смешал две схемы. if m.Name == sleepMetric && head.TotalSleep != nil { p.Metric = sleepSummaryMetric g.summaries = append(g.summaries, p) // Наблюдение снимается по ИТОГОВОМУ имени метрики, тому же, которым // адресуется единица хранения. У суточной сводки категориальных // полей нет, так что здесь это ноль работы, — но правило записано // один раз и не разойдётся при следующем разделении схем. cat.addPoint(p.Metric, &head) continue } cat.addPoint(p.Metric, &head) g.points = append(g.points, p) } g.dense = len(g.points) >= denseThreshold g.alignment = finestAlignment(g.points) return g } // Имена метрик сна: пришедшее от HAE и наше для суточной сводки. const ( sleepMetric = "sleep_analysis" sleepSummaryMetric = "sleep_analysis_summary" ) // parseTime разбирает метку точки. Начало берётся из start, а при его // отсутствии — из date. Измерено: start, когда он есть, всегда совпадает с // date, поэтому правило не вводит второго источника метки — оно закрывает // случай, когда HAE перестанет их дублировать. func parseTime(date, start string) (time.Time, bool) { s := start if s == "" { s = date } if s == "" { return time.Time{}, false } t, err := time.Parse(timeLayout, s) if err != nil { return time.Time{}, false } return t, true } // finestAlignment возвращает самое мелкое выравнивание среди меток. // // Именно самое мелкое, а не преобладающее: у плотных метрик выравнивания // перемешаны (active_energy — 1320 минутных меток и 21 часовая), потому что // метка ровно на часе одновременно является и минутной. Метрика, у которой // хоть одна метка стоит на середине часа, часовой не является. func finestAlignment(points []Point) Layer { finest := LayerHour for _, p := range points { // По МЕСТНОЙ метке, а не по UTC: HAE строит сетку в зоне телефона. // В зонах с получасовым смещением (+0530 Индия, +0545 Непал, +0930 // Аделаида) ровный местный час превращается в UTC-метку на половине, // и вся часовая выгрузка уехала бы в слой `minute` — а там столкнулась // бы с настоящей минутной автоматизацией на координате hh:30 и завысила // сумму минутного слоя вдвое. switch { case p.local.Second() != 0 || p.local.Nanosecond() != 0: return LayerRaw case p.local.Minute() != 0: finest = LayerMinute } } return finest } // headerLayer переводит заголовок в слой, но только когда заголовок надёжен. // `Default` соответствует трём разным режимам выгрузки и не означает ничего. func headerLayer(aggregation string) Layer { switch aggregation { case "Minutes": return LayerMinute case "Hours": return LayerHour default: return "" } } // finer возвращает более мелкий из двух слоёв. func finer(a, b Layer) Layer { if rank(a) < rank(b) { return a } return b } func rank(l Layer) int { switch l { case LayerSample: return 0 case LayerRaw: return 1 case LayerMinute: return 2 case LayerHour: return 3 case LayerDay: return 4 default: return 5 } }