Golang: створення OpenAI Exporter для VictoriaMetrics

Автор |  17/11/2025
 

Є задачка на моніторинг костів на OpenAI – бачити скільки за добу витрачено кожним проектом, і слати алерти в Slack, коли витрати завеликі.

Потикав кілька готових експортерів для OpenAI, але не побачив там метрик саме по костам, тому просто напишемо свій.

Писати будемо на Golang, ідея дуже проста – з OpenAI API отримуємо дані, генеруємо метрику, відправляємо її до VictoriaMetrics.

На Go останній раз писав у 2019 році, і то один раз, тому заодно будемо згадувати що і як працює, і місцями дивитись деталі реалізації різних бібліотек.

Поїхали.

OpenAI API

Документація по OpenAI API – Costs та повертаєме значення – Costs object.

Для доступу до Costs потрібен окремий ключ – робимо на platform.openai.com в Admin keys:

Для отримання Costs треба задавати параметр start_time в Unix форматі – створюємо змінну:

$ TODAY=$(date -d "$(date +%Y-%m-%d) 00:00:00" +%s)
$ echo $TODAY
1762898400

І перевіряємо доступ з curl:

$ curl -s -H "Content-Type: application/json" -H "Authorization: Bearer $OPENAI_ADMIN_KEY" "https://api.openai.com/v1/organization/costs?start_time=$TODAY"
{
  "object": "page",
  "has_more": false,
  "next_page": null,
  "data": [
    {
      "object": "bucket",
      "start_time": 1762819200,
      "end_time": 1762905600,
      "results": [
        {
          "object": "organization.costs.result",
          "amount": {
            "value": 5.65750295,
            "currency": "usd"
...

ОК, доступ працює – поїхали до Golang.

Golang gore REPL

Для швидкого тестування функцій можна встановити gore:

$ go install github.com/x-motemen/gore/cmd/gore@latest

Запускаємо (не забуваємо про $GOPATH/bin в $PATH) і перевіряємо отримання поточної дати і часу:

$ gore
gore version 0.6.1  :help for help
gore> :import time
gore> time.Now()
%!t(int64=1763025027)2025-11-13 11:10:27 Local

Ну або просто користуватись Go Playground.

Створення Golang API client

Спочатку напишемо клієнт, який буде звертатись до API та виводити отриманий результат на консоль, а потім допишемо генерацію метрик.

Що нам треба для запитів:

  • мати URL
  • мати час
  • отриманий результат поки просто виводимо на консоль

Створюємо каталог проекту, виконуємо ініціалізацію:

$ mkdir ~/Work/atlas-monitoring/exporters/openai-exporter
$ go mod init openai-exporter

Для API-клієнту можемо використати стандартну бібліотеку net/http, або більш спеціалізовані типу resty  або sling.

Вирішив спробувати resty, бо і цікаво, і код виглядає приємніше, і зручно передавати параметри.

Документація по resty – тут>>> та тут>>>.

Вже є версія 3, але вона ще в beta, тому беремо другу.

Спробуємо з resty спочатку в gore:

gore> :import "github.com/go-resty/resty/v2"
gore> client := resty.New()
...
gore> resp, err := client.R().Get("https://httpbin.org/get")
...
gore> fmt.Println(resp, err)
...
313
nil

Для виконання API-запитів спочатку викликом методу New() створюємо об’єкт type Client struct, а далі з методом R() (request) робимо виклики.

Документація по New() тут>>>, її код тут>>>.

Не дуже зручно, що типи в Golang не описують пов’язаних методів – але їх можна побачити в go doc:

$ go doc github.com/go-resty/resty/v2.Client | grep "New()\|R()"
func New() *Client
func (c *Client) R() *Request

В розділі Usage є такий приклад:

...
resp, err := client.R().
    EnableTrace().
    Get("https://httpbin.org/get")
...

Де resty використовує method chaining, коли методи якогось типу повертають той самий тип.

Як це виглядає:

  • з функцією resty.New() ми створюємо клієнт – New() повертає *Client struct з його пов’язаними методами
  • для Client struct є метод R(), який повертає *Request struct
  • для структури Request маємо метод EnableTrace() який теж повертає Request
  • і для того ж Request маємо метод Get(), який теж повертає Request плюс error

І це дозволяє нам будувати ланцюжки запитів – Client => R() => Request => EnableTrace() => Request => Get().

Окей, давайте до коду.

Створення resty клієнта

Пишемо main.go:

package main

import (
  "fmt"

  "github.com/go-resty/resty/v2"
)

// set global const as ay be used in other packages
const (
  baseURL   = "https://api.openai.com/v1"
  costsPath = "/organization/costs"
)

func main() {
  client := resty.New()

  // build 'https://api.openai.com/v1/organization/costs'
  response, err := client.R().Get(baseURL + costsPath)
  if err != nil {
    panic(err)
  }

  fmt.Println(response)
}

Запускаємо:

$ go run main.go
{
  "error": {
    "message": "You didn't provide an API key. You need to provide your API key in an Authorization header using Bearer auth (i.e. Authorization: Bearer YOUR_KEY). You can obtain an API key from https://platform.openai.com/account/api-keys."
...

Супер, працює.

Тепер додамо отримання API ключа зі змінної.

Використовуємо os:

...
import (
  "fmt"
  "os"
...
func main() {
  client := resty.New()

  apiKey = os.Getenv("OPENAI_ADMIN_KEY")
...

Далі нам треба додати auth header до нашого запиту – використовуємо метод func (*Client) SetAuthToken, який просто додає значення до поля Token в об’єкті Client.

Ще є окремий метод func (r *Request) SetAuthToken, який задає токен на конкретні реквести, а не на весь клієнт, але в нашому випадку робимо простіше, через загальний Client.

Робимо method chaining із прикладу вище – для Client викликаємо SetAuthToken(), який задає токен, наступним викликаємо R() для створення request, і наступним викликаємо Get(), в який передаємо URL:

...
  apiKey := os.Getenv("OPENAI_ADMIN_KEY")

  // build 'https://api.openai.com/v1/organization/costs'
  response, err := client.SetAuthToken(apiKey).R().Get(baseURL + costsPath)
...

Перевіряємо:

$ go run main.go
{
  "error": {
    "message": "Missing query parameter 'start_time'",
...

ОК – аутентифікацію ми пройшли, тепер треба додати параметри.

Тут у нас є цілих чотири варіанти:

Зараз нам треба тільки start_time, але потім будемо додавати ще, тому можна їх відразу записати в map, який потім передамо до SetQueryParams().

Для start_time нам треба передати час – робимо з time.Now(), і передавати дату до OpenAI API нам треба в Unix форматі, тому використовуємо функцію Unix().

Перевіряємо як воно буде виглядати:

gore> :import time
gore> timeNow := time.Now().Unix()
1762956432

Додаємо в код створення змінної timeNow з часом, створення setQueryParams map of strings зі списком параметрів теж в strings, і додаємо виклик SetQueryParams() до client:

...
  timeNow := time.Now().Unix()

  setQueryParams := map[string]string{
    "start_time": timeNow,
  }

  // build 'https://api.openai.com/v1/organization/costs'
  response, err := client.SetAuthToken(apiKey).
    R().SetQueryParams(setQueryParams).
    Get(baseURL + costsPath)
...

Але якщо викликати цей код зараз, то буде помилка, бо timeNow := time.Now().Unix() повертається в int64:

gore> fmt.Printf("%t", timeNow)
%!t(int64=1762957173)21
nil

А в setQueryParams() нам треба передати string, бо SetQueryParams() приймає map зі string:

func (r *Request) SetQueryParams(params map[string]string) *Request

Тому конвертуємо нашу змінну timeNow в string зі strconv.FormatInt():

gore> :import strconv
gore> s := strconv.FormatInt(timeNow, 10)
gore> fmt.Printf("%t", s)
%!t(string=1763371451)22

І можемо зробити нашу змінну timeNow так:

...
timeNow := strconv.FormatInt(time.Now().Unix(), 10)
...

Запускаємо, перевіряємо:

$ go run main.go
{
  "object": "page",
  "has_more": false,
  "next_page": null,
  "data": [
    {
      "object": "bucket",
      "start_time": 1762905600,
      "end_time": 1762992000,
      "results": [
        {
          "object": "organization.costs.result",
          "amount": {
            "value": 6.442440250000003,
            "currency": "usd"
          },
          "line_item": null,
          "project_id": null,
          "organization_id": "org-ORG"
...

Чудово, маємо потрібні дані.

Тепер треба додати ще один параметр – group_by=project_id:

...
  setQueryParams := map[string]string{
    "start_time": timeNow,
    "group_by":   "project_id",
  }
...

І тепер в результатах маємо дані по кожному project_id:

$ go run main.go
{
  "object": "page",
  "has_more": false,
  "next_page": null,
  "data": [
    {
      "object": "bucket",
      "start_time": 1762905600,
      "end_time": 1762992000,
      "results": [
        {
          "object": "organization.costs.result",
          "amount": {
            "value": 1.76643575,
            "currency": "usd"
          },
          "line_item": null,
          "project_id": "proj_1",
          "organization_id": "org-ORG"
        },
        {
          "object": "organization.costs.result",
          "amount": {
            "value": 0.47790999999999995,
            "currency": "usd"
          },
          "line_item": null,
          "project_id": "proj_2",
          "organization_id": "org-ORG"
        },
...

Далі нам треба отриманий результат зберігти в якусь змінну для подальшої роботи.

resty JSON Unmarshall

resty підтримує автоматичний JSON unmarshalling через метод SetResult():

func (r *Request) SetResult(res interface{}) *Request {
  if res != nil {
    r.Result = getPointer(res)
  }
  return r
}

Він приймає аргументом тип any (interface{}), передає його до своєї функції getPointer(), де виконується перевірка – чи це тип pointer:

func getPointer(v interface{}) interface{} {
  vv := valueOf(v)
  if vv.Kind() == reflect.Ptr {
    return v
  }
  ...
}

І тоді SetResult() викликаючи parseResponseBody() записує значення з Request.Result до об’єкта, який був переданий аргументом до SetResult():

...
  // default after response middlewares
  c.afterResponse = []ResponseMiddleware{
    parseResponseBody,
    saveResponseIntoFile,
  }
...

А в функції parseResponseBody() викликається метод Unmarshalc, який в свою чергу викликає Client.JSONUnmarshal(), а поле JSONUnmarshal містить функцію json.Unmarshal():

...
func createClient(hc *http.Client) *Client {
  if hc.Transport == nil {
    hc.Transport = createTransport(nil)
  }

  c := &Client{ // not setting lang default values
                ...
    JSONUnmarshal:          json.Unmarshal,
...

Див. код resty/v2/client.go.

Отже, ми отримуємо результат в JSON, і через SetResult() можемо зберігти потрібні поля в якийсь об’єкт.

Створення Go struct для JSON Unmarshall

Давайте подумаємо над тим, як ми хочемо сформувати дані.

У нас є project_id та amount – скільки цей проект витратив, це ми отримуємо з OpenAI API /organization/costs.

У нас також є Project Names, які ми можемо отримати з /organization/projects, але про це трохи далі.

В результаті ми можемо побудувати щось таке:

[
  {
    "project_id": "Id1",
    "project_name": "Name1",
    "project_spend": 100
  },
  {
    "project_id": "Id2",
    "project_name": "Name2",
    "project_spend": 200
  }
]

Що для цього є в Go?

  • array, масив: фіксована довжина, індексований тип, всі об’єкти того самого типу – [3]int{1,2,3}
  • slice: аналогічний до array, але не фіксованої довжини – []int{1,2,3}
  • maps: набір key:value елементів змінної довжини одного типу – map[string]string{"key_name": "value_value"}
  • structs: комплексний тип, який може включати в себе інші типи – struct{ Name string; Age int }{ Name: "Nino", Age: 35 }

Так як ми знаємо, які типи ми отримуємо з API та всі поля в них – то нам підійде slice of structs, де кожен елемент slice буде структурою з полями, в яких ми будемо зберігати project_id, amount та project_name.

Структура для Project ID та Amount

Структура може виглядати так:

type ProjectSpend struct {
  ProjectID    string
  ProjectSpend int
}

А потім створимо slice з цією структурою:

data := []ProjectSpend{}

Тепер давайте подивимось на те, що нам повертає OpenAI API.

Від /organization/costs:

{
  "object": "page",
  "has_more": false,
  "next_page": null,
  "data": [
    {
      "object": "bucket",
      "start_time": 1763078400,
      "end_time": 1763164800,
      "results": [
        {
          "object": "organization.costs.result",
          "amount": {
            "value": 2.16911625,
            "currency": "usd"
          },
          "line_item": null,
          "project_id": "proj_1",
          "organization_id": "org-ORG"
        },
        {
          "object": "organization.costs.result",
          "amount": {
            "value": 0.1846203,
            "currency": "usd"
          },
          "line_item": null,
          "project_id": "proj_2",
          "organization_id": "org-ORG"
        },
        ...
      ]
    }
  ]
}

Тут у нас виходить така структура:

  • починається з JSON object {}
    • має кілька JSON properties –  "object": "page", etc
    • далі йде масив data []
      • який містить в собі інший object {}
        •  який починається з properties "object": "bucket", etc
        • і в якому є інший масив results []
          • який включає в себе ще один object {}
            • який починається із property "object": "organization.costs.result"
            • за яким слідує property amount, який містить в собі вкладений object {}
              • з двома property – value та value

Якщо ми хочемо це відобразити в Go struct – то нам потрібно створити кілька структур, які будуть передавати дані одна до одної:

  • перша структура “захоплює” data[]
    • друга структура – отримує results[]
      • третя – отримує значення поля project_id
        • а четверта – зчитує amount

Як це може виглядати в коді – з використання structs composition, коли одна структура містить в собі поле, яке є іншою структурою:

type ResponceAmount struct {
  Value float64
}

type ResponceProjectID struct {
  ProjectID string `json:"project_id"`
  Amount    ResponceAmount
}

type ResponseResults struct {
  Results []ResponceProjectID
}

type ResponseData struct {
  Data []ResponseResults
}

res := &ResponseData{}

І тепер можемо виконати json.Unmarshall через виклик SetResult(), в який ме передаємо pointer – res := &ResponseData{}:

...
  _, err := client.SetAuthToken(apiKey).
    R().SetQueryParams(setQueryParams).
    SetResult(res).
    Get(baseURL + costsPath)

fmt.Println("Result: ", res)
...

Отримуємо такий результат:

$ go run main.go
...
Result:  &{[{[{proj_1 {2.16911625}} {proj_Agtar0XzJdXXLhGt8YCRNZMY {0.1846203}} {proj_2 {0.1531728}} {proj_3 {0.19788874999999997}}]}]}

Або можемо зробити більш лаконічно – використовуючи nested anonymous structs:

...
  // catch data[] and pass to nested struct
  // catch results[] and pass to next nested struct
  // catch 'project_id' property to the 'ProjectID' field, and pass to next nested struct
  // catch 'amount' property to the 'Amount' field, and pass to next nested struct
  // finally, catch 'value' property to the 'Value' field
  type ResponseData struct {
    Data []struct {
      Results []struct {
        ProjectID string `json:"project_id"`
        Amount    struct {
          Value float64
        }
      }
    }
  }
...

І отримаємо той самий результат.

А далі нам потрібно буде згенерувати метрики з лейблами.

Робимо це у два цикли for, в яких перебираємо поля кожної структури:

...
  // catch each item from the 'Response.Data[]'
  for _, dataItem := range res.Data {
    // catch each iteam from the 'Response.Data[].Results[]'
    for _, result := range dataItem.Results {

      project := result.ProjectID
      amount := result.Amount.Value

      // print in VictoriaMetrics gauge format
      fmt.Printf("openai_stats{type=\"costs\", project=\"%s\"} %f\n", project, amount)
    }
  }
...

Результат:

$ go run main.go
openai_stats{type="costs", project="proj_1"} 2.170784
openai_stats{type="costs", project="proj_2"} 0.241411
openai_stats{type="costs", project="proj_3"} 0.213558
openai_stats{type="costs", project="proj_4"} 0.198619

А тепер зробимо аналогічно, але для імен проектів, бо мати в лейблах метрик значення у вигляді “proj_123” зовсім незручно, хочеться вивести нормальні імена.

Структура для Project Names

Додаємо другий ендпоінт, див. документацію List projects:

...
const (
  baseURL      = "https://api.openai.com/v1"
  costsPath    = "/organization/costs"
  projectsPath = "/organization/projects"
)
...

А виконання запитів до OpenAI виносимо в окрему функцію:

...
func getOpenAi(client *resty.Client, path string, out any) error {
  _, err := client.R().
    SetResult(out).
    Get(path)
  return err
}
...

Додавання OPENAI_ADMIN_KEY ключа і параметрів переносимо в створення клієнта, після чого викликаємо нашу функцію, якій передаємо створений і налаштований клієнт:

...
func main() {
  //client := resty.New()

  apiKey := os.Getenv("OPENAI_ADMIN_KEY")
  timeNow := strconv.FormatInt(time.Now().Unix(), 10)

  setQueryParams := map[string]string{
    "start_time": timeNow,
    "group_by":   "project_id",
  }

  // use pointer to ResponseData struct
  // as 'json.Unmarshal' requires a pointer to write results
  costsRes := &CostsResponseData{}

  client := resty.New().
    SetAuthToken(apiKey).
    SetQueryParams(setQueryParams)

  getOpenAi(client, baseURL+costsPath, costsRes)

  fmt.Println("Result: ", costsRes)
...

Запускаємо:

$ go run main.go
Result:  &{[{[{proj_1 {2.1707842499999996}} {proj_2 {0.24141089999999998}} {proj_3 {0.21355799999999994}} {proj_4 {0.46123659999999994}}]}]}
openai_stats{type="costs", project="proj_1"} 2.170784
openai_stats{type="costs", project="proj_2"} 0.241411
...

Тепер переходимо до отримання імен проектів.

Запит до api.openai.com/v1/organization/projects нам поверне дані в такому форматі:

{
    "object": "list",
    "data": [
        {
            "id": "proj_abc",
            "object": "organization.project",
            "name": "Project example",
            "created_at": 1711471533,
            "archived_at": null,
            "status": "active"
        }
    ],
    "first_id": "proj-abc",
    "last_id": "proj-xyz",
    "has_more": false
}

Робимо аналогічно до отримання костів – створюємо структуру:

...
type ProjectsResponse struct {
  Data []struct {
    ID   string
    Name string
  }
}
...

І в main() додаємо другий виклик getOpenAi() та обробку помилок:

...
  // use pointer to ResponseData struct
  // as 'json.Unmarshal' requires a pointer to write results
  costsRes := &CostsResponseData{}
  if err := getOpenAi(client, baseURL+costsPath, costsRes); err != nil {
    panic(err)
  }

  projectsRes := &ProjectsResponse{}
  if err := getOpenAi(client, baseURL+projectsPath, projectsRes); err != nil {
    panic(err)
  }

  fmt.Println("Costs Result: ", costsRes)
  fmt.Println("Projects Result: ", projectsRes)
...

Маємо такий результат:

$ go run main.go
Costs Result:  &{[{[{proj_1 {2.1707842499999996}} {proj_2 {0.24141089999999998}} {proj_3 {0.21355799999999994}} {proj_4 {0.46123659999999994}}]}]}
Projects Result:  &{[{proj_1 Default project} {proj_2 Assistant Test/Eval} {proj_3 Kraken Production} {proj_4 Knowledge Base}]}
...

sanitize імен – форматування даних зі strings.Replace()

Але в іменах у нас є пробіли та символи “/”, і імена проектів містять заглавні букви – а нам в лейблах метрик треба мати вид “my_project_name“.

Додамо функцію, яка буде виконувати нормалізацію використовуючи методи ToLower() та ReplaceAll() із пакету strings:

...
func normalizeLabel(s string) string {
  s = strings.ToLower(s)
  s = strings.ReplaceAll(s, " ", "_")
  s = strings.ReplaceAll(s, "/", "_")
  return s
}
...

Наступний крок – побудувати map, в якій ми будемо мати project_id та project_names:

...
  projectNames := make(map[string]string)

  // get each 'ProjectsResponse.Data[].ID'
  // get each 'ProjectsResponse.Data[].Name'
  // populate the projectNames map with:
  // 'project_id' = 'project_name'
  for _, p := range projectsRes.Data {
    projectNames[p.ID] = normalizeLabel(p.Name)
  }

  fmt.Println("Projects Names: ", projectNames)
...

В результаті маємо:

$ go run main.go
Projects Names:  map[proj_1:kraken_production proj_2:assistant_test_eval proj_3:knowledge_base proj_4:default_project]

І тепер оновлюємо наші два цикли – використовуємо в лейблі імена замість ID:

...
  // catch each item from the 'Response.Data[]'
  for _, dataItem := range costsRes.Data {
    // catch each item from the 'Response.Data[].Results[]'
    for _, result := range dataItem.Results {

      // get ''Response.Data[].Results[].ProjectID'
      id := result.ProjectID

      // get ''Response.Data[].Results[].Amount.Value'
      amount := result.Amount.Value

      // use the 'id' to get the project name from the projectNames map
      project := projectNames[id]
      if project == "" {
        project = "unknown"
      }

      // print in VictoriaMetrics gauge format
      fmt.Printf("openai_stats{type=\"costs\", project=\"%s\"} %f\n", project, amount)
    }
  }
...

І результат:

$ go run main.go
openai_stats{type="costs", project="knowledge_base"} 2.170784
openai_stats{type="costs", project="kraken_production"} 0.241411
openai_stats{type="costs", project="assistant_test_eval"} 1.083077
openai_stats{type="costs", project="default_project"} 0.461237

Зараз весь код у нас такий:

package main

import (
  "fmt"
  "os"
  "strconv"
  "strings"
  "time"

  "github.com/go-resty/resty/v2"
)

// set global const as ay be used in other packages
const (
  baseURL      = "https://api.openai.com/v1"
  costsPath    = "/organization/costs"
  projectsPath = "/organization/projects"
)

// catch data[] and pass to nested struct
// catch results[] and pass to next nested struct
// catch 'project_id' property to the 'ProjectID' field, and pass to next nested struct
// catch 'amount' property to the 'Amount' field, and pass to next nested struct
// finally, catch 'value' property to the 'Value' field
type CostsResponseData struct {
  Data []struct {
    Results []struct {
      ProjectID string `json:"project_id"`
      Amount    struct {
        Value float64
      }
    }
  }
}

type ProjectsResponse struct {
  Data []struct {
    ID   string
    Name string
  }
}

func getOpenAi(client *resty.Client, path string, out any) error {
  _, err := client.R().
    SetResult(out).
    Get(path)
  return err
}

func normalizeLabel(s string) string {
  s = strings.ToLower(s)
  s = strings.ReplaceAll(s, " ", "_")
  s = strings.ReplaceAll(s, "/", "_")
  return s
}

func main() {
  //client := resty.New()

  apiKey := os.Getenv("OPENAI_ADMIN_KEY")
  timeNow := strconv.FormatInt(time.Now().Unix(), 10)

  setQueryParams := map[string]string{
    "start_time": timeNow,
    "group_by":   "project_id",
  }

  client := resty.New().
    SetAuthToken(apiKey).
    SetQueryParams(setQueryParams)

  // use pointer to ResponseData struct
  // as 'json.Unmarshal' requires a pointer to write results
  costsRes := &CostsResponseData{}
  if err := getOpenAi(client, baseURL+costsPath, costsRes); err != nil {
    panic(err)
  }

  projectsRes := &ProjectsResponse{}
  if err := getOpenAi(client, baseURL+projectsPath, projectsRes); err != nil {
    panic(err)
  }

  projectNames := make(map[string]string)

  // get each 'ProjectsResponse.Data[].ID'
  // get each 'ProjectsResponse.Data[].Name'
  // populate the projectNames map with:
  // 'project_id' = 'project_name'
  for _, p := range projectsRes.Data {
    projectNames[p.ID] = normalizeLabel(p.Name)
  }


  // catch each item from the 'Response.Data[]'
  for _, dataItem := range costsRes.Data {
    // catch each item from the 'Response.Data[].Results[]'
    for _, result := range dataItem.Results {

      // get ''Response.Data[].Results[].ProjectID'
      id := result.ProjectID

      // get ''Response.Data[].Results[].Amount.Value'
      amount := result.Amount.Value

      // use the 'id' to get the project name from the projectNames map
      project := projectNames[id]
      if project == "" {
        project = "unknown"
      }

      // print in VictoriaMetrics gauge format
      fmt.Printf("openai_stats{type=\"costs\", project=\"%s\"} %f\n", project, amount)
    }
  }
}

Тепер можемо переходити до формування реальних метрик та записати їх до VictoriaMetrics.

Планування метрик для VictoriaMetrics

Отже, метрики у нас будуть у вигляді openai_stats{type="costs", project="prodject_id"} 5.55.

А що сказано в задачі, що треба в результаті?

якщо денний спендінг на опенаі перевищує середній за останні дні (з певним трешолдом) – кричати в слак

Значить, нам потрібна буде сума за добу, і маючи її, ми можемо робити порівняння з попередніми періодами часу.

А що нам повертається в API?

Дивимось Costs object:

The aggregated costs details of the specific time bucket.

А що ми маємо, коли робимо запит тільки зі start_time без end_time?

Дивимось час в отриманому response:

...
      "start_time": 1762905600,
      "end_time": 1762992000,
...

Тут start_time буде:

$ date -d @1762905600
Wed Nov 12 02:00:00 EET 2025

А end_time:

$ date -d @1762992000
Thu Nov 13 02:00:00 EET 2025

Це 00:00 в UTC.

Тобто, повертає суму витрати за сьогоднішній день, за поточну добу, бо цей текст писався 12-го числа.

Отже тут:

...
      "start_time": 1762905600,
      "end_time": 1762992000,
      ...
            "value": 1.76643575,
            "currency": "usd"
          },
          "line_item": null,
          "project_id": "proj_1",
...

Бачимо, що за сьогодні проект з ID “proj_1” витратив 1.76643575 бакси.

Окей…

Як ми можемо це зберігати в метрику? Зробити тип Counter, який постійно збільшується, і кожну годину його оновлювати?

Тоді тайм-серія (див. Що таке Time Series?) по цій метриці буде виглядати якось так:

openai_stats{type="costs", project="prodject_id"}
  1762960223 1.76
  1762960237 1.80
  1762960249 1.95

І потім можемо для алерту створити запит на кшталт такого:

if 
avg_over_time(openai_stats{type="costs", project="prodject_id"}[1d)
> 
avg_over_time(openai_stats{type="costs", project="prodject_id"}[3d)
then send alert

Але з Counter є нюанс – він обнуляється, якщо експортер перезапуститься – див. counter reset.

Крім того, якщо ми отримуємо дані починаючи з 00:00 – то з наступного дня значення буде починатись з 0,00 USD.

А значить, у нас значення в метриці може і збільшуватись, і зменшуватись, а значить – нам потрібен не Counter, а Gauge.

VictoriaMetrics Go client

Є бібліотека для Prometheus, але так як у нас VictoriaMetrics – то беремо їхній пакет, який до того ж має функцію PushMetrics(), з якою ми можемо відразу пушити метрики до VictoriaMetrics.

Дивимось документацію по type Gauge, там є приклад створення об’єкта метрики.

Функція NewGauge() приймає два аргументи – ім’я метрики з лейблами та функцію, яка виконує оновлення значення для цієї метрики, див. gauge.go:

func NewGauge(name string, f func() float64) *Gauge {
  return defaultSet.NewGauge(name, f)
}

Але якщо ми хочемо задавати дані самі – то замість передачі другого аргументу f func() – можемо передати просто nil, а потім використати метод Set().

Пробуємо, як це працює з nil та Set():

gore> :import "github.com/VictoriaMetrics/metrics"
gore> g := metrics.NewGauge(`test_gauge`, nil)
gore> g.Set(9.00)
gore> :import fmt
gore> fmt.Println(g.Get())
9

Супер.

Тепер думає як ми будемо все це діло робити.

Нам потрібно:

  • створити metrics.NewGauge() на кожну метрику
  • потім в циклі раз на годину отримувати дані з API
  • для кожної метрики виконувати Set()

Тобто метрики генеруємо кожну зі своїм значенням лейбли project:

project_1 := metrics.NewGauge(openai_stats{type="costs", project="prodject_1"}) 
project_2 := metrics.NewGauge(openai_stats{type="costs", project="prodject_2"}) 
project_3 := metrics.NewGauge(openai_stats{type="costs", project="prodject_3"})

А потім для кожного project_N виконуємо Set().

У нас зараз є цикл, який заповнює fmt.Printf("openai_stats{type=\"costs\", project=\"%s\"} %f\n", project, amount).

Давайте спочатку прямо в нього додамо генерацію метрик, подивимось, як воно може виглядати.

Для виводу на консоль використовуємо функцію metrics.WritePrometheus(), яка пише в Prometheus-форматі в канал, який задається першим аргументом.

Після циклів додаємо:

...
      // print in VictoriaMetrics gauge format
      //fmt.Printf("openai_stats{type=\"costs\", project=\"%s\"} %f\n", project, amount)
      metricName := fmt.Sprintf(`test_openai_stats{type="costs", project="%s"}`, project)
      gauge := metrics.NewGauge(metricName, nil)
      gauge.Set(amount)

    }
  }

  metrics.WritePrometheus(os.Stdout, false)
...

В результаті маємо такий результат:

$ go run main.go
test_openai_stats{type="costs", project="assistant_test_eval"} 4.9838991
test_openai_stats{type="costs", project="default_project"} 0.5281144000000001
test_openai_stats{type="costs", project="knowledge_base"} 2.17244425
test_openai_stats{type="costs", project="kraken_production"} 0.5510669499999999

Супер.

А тепер подумаємо над всією логікою виконання.

Що у нас є зараз:

  1. створення resty.Client
  2. ініціалізація структури costsRes := &CostsResponseData{}
  3. виклик getOpenAi() з аргументами (client, baseURL+costsPath, costsRes), де ми заповнюємо дані в структурі CostsResponseData
  4. ініціалізація projectsRes := &ProjectsResponse{}
  5. виклик getOpenAi() з аргументами (client, baseURL+projectsPath, projectsRes), де ми заповнюємо дані в структурі ProjectsResponse
  6. ініціалізація мапи projectNames
  7. заповнення її з даними "project_id": "project_name"
  8. далі цикли, в яких:
    1. отримуємо project_id
    2. отримуємо amount
    3. по project_id отримуємо ім’я проекту, записуємо в змінну project
    4. генеруємо ім’я метрики і лейблу з project в metricName
    5. з metrics.NewGauge генеруємо нову метрику
    6. з gauge.Set(amount) записуємо в неї значення
  9. з metrics.WritePrometheus() всі згенеровані метрики виводимо на консоль

І все це зараз виконується при виклику main().

Натомість нам при виклику main(), тобто при старті експортера, треба:

  1. створити resty.Client
  2.  далі періодично виконувати оновлення даних та записувати дані до VictoriaMetrics:
    1. з getOpenAi() заповнити структуру ProjectsResponse
    2. з getOpenAi() заповнити структуру CostsResponseData
    3. заповнити projectNames
    4. запустити цикли для генерації метрик і виконання Set()
    5. в кінці циклу виконати WritePrometheus()

Правда, при такому підході ми кожну годину будемо перезаписувати поля в ProjectsResponse, CostsResponseData та projectNames, що наче не дуже ОК з точки зору перформансу – але якщо у нас з’явиться новий проект, то ми його відразу “спіймаємо”, і додамо нову метрику для нього.

Отже, що треба зробити – це винести нашу логіку в окрему функцію, раз на годину викликати її, а потім виконувати WritePrometheus().

Пишемо цю функцію, тільки  міняємо NewGauge() на GetOrCreateGauge(), бо при наступному виклику нашої функції метрики вже будуть створені:

...
func fetchAndPush(client *resty.Client, costsRes *CostsResponseData, projectsRes *ProjectsResponse, projectNames map[string]string) {
  if err := getOpenAi(client, baseURL+costsPath, costsRes); err != nil {
    panic(err)
  }
  if err := getOpenAi(client, baseURL+projectsPath, projectsRes); err != nil {
    panic(err)
  }
  // get each 'ProjectsResponse.Data[].ID'
  // get each 'ProjectsResponse.Data[].Name'
  // populate the projectNames map with:
  // 'project_id' = 'project_name'
  for _, p := range projectsRes.Data {
    projectNames[p.ID] = normalizeLabel(p.Name)
  }

  // catch each item from the 'Response.Data[]'
  for _, dataItem := range costsRes.Data {
    // catch each item from the 'Response.Data[].Results[]'
    for _, result := range dataItem.Results {

      // get 'Response.Data[].Results[].ProjectID'
      // i.e. 'proj_123'
      id := result.ProjectID

      // get 'Response.Data[].Results[].Amount.Value'
      amount := result.Amount.Value

      // use the 'id' to get the project name from the projectNames map
      project := projectNames[id]
      if project == "" {
        project = "unknown"
      }

      // print in VictoriaMetrics gauge format
      //fmt.Printf("openai_stats{type=\"costs\", project=\"%s\"} %f\n", project, amount)
      metricName := fmt.Sprintf(`test_openai_stats{type="costs", project="%s"}`, project)
      gauge := metrics.GetOrCreateGauge(metricName, nil)
      gauge.Set(amount)

    }
  }

  metrics.WritePrometheus(os.Stdout, false)
}
...

Тепер в main() у нас залишається:

...
func main() {
  //client := resty.New()

  apiKey := os.Getenv("OPENAI_ADMIN_KEY")
  timeNow := strconv.FormatInt(time.Now().Unix(), 10)

  setQueryParams := map[string]string{
    "start_time": timeNow,
    "group_by":   "project_id",
  }

  client := resty.New().
    SetAuthToken(apiKey).
    SetQueryParams(setQueryParams)

  // use pointer to ResponseData struct
  // as 'json.Unmarshal' requires a pointer to write results
  costsRes := &CostsResponseData{}

  projectsRes := &ProjectsResponse{}

  // will be populated with key:value pairs:
  // 'proj_123' = 'kraken_production'
  projectNames := make(map[string]string)

  fetchAndPush(client, costsRes, projectsRes, projectNames)
}

Запускаємо для перевірки:

$ go run main.go
test_openai_stats{type="costs", project="assistant_test_eval"} 6.3417053
test_openai_stats{type="costs", project="default_project"} 0.6592560500000001
test_openai_stats{type="costs", project="knowledge_base"} 2.17244425
test_openai_stats{type="costs", project="kraken_production"} 0.6170747

Тепер нам треба замість простого виводу на консоль записати дані до VictoriaMetrics.

Запис метрик до VictoriaMetrics з InitPush() та PushMetrics()

Для запису метрик до VictoriaMetrics маємо дві основні функції – InitPush() та PushMetrics().

Функція InitPush()

Функція InitPush() дозволяє виконувати періодичні записи із заданим interval, а PushMetrics() – просто разово записати всі метрики, які є в Set struct. Про Set трохи далі.

Тепер просто інтересу заради розберемо, як саме VictoriaMetrics клієнт виконує запис.

Знаходимо код InitPush():

func InitPush(pushURL string, interval time.Duration, extraLabels string, pushProcessMetrics bool) error {
  writeMetrics := func(w io.Writer) {
    WritePrometheus(w, pushProcessMetrics)
  }
  return InitPushExt(pushURL, interval, extraLabels, writeMetrics)
}

Тут:

  • ми в нашому коді викликаємо InitPush(), передаємо до цієї функції URL та інтервал
  • InitPush() створює змінну writeMetrics – анонімну функцію, яка приймає аргумент типу io.Writer, і яка потім буде викликати функцію WritePrometheus(), в яку передається цей io.Writer
  • далі викликається функція InitPushExt(), якій передається pushURL, interval, та об’єкт writeMetrics

Дивимось на InitPushExt():

func InitPushExt(pushURL string, interval time.Duration, extraLabels string, writeMetrics func(w io.Writer)) error {
  opts := &PushOptions{
    ExtraLabels: extraLabels,
  }
  return InitPushExtWithOptions(context.Background(), pushURL, interval, writeMetrics, opts)
}

Тут просто додаються параметри зі структури PushOptions, в яку можемо передати параметри типу extraLabels, і потім викликається InitPushExtWithOptions(), в яку передається наш writeMetrics.

Дивимось InitPushExtWithOptions(): тут створюється goroutine, яка із заданим interval викликає pushMetrics(), в яку передається наш об’єкт writeMetrics (тобто та анонімна функція, яка буде викликати WritePrometheus()):

func InitPushExtWithOptions(ctx context.Context, pushURL string, interval time.Duration, writeMetrics func(w io.Writer), opts *PushOptions) error {
  pc, err := newPushContext(pushURL, opts)
        ...
  go func() {
    ticker := time.NewTicker(interval)
                ...
        ctxLocal, cancel := context.WithTimeout(ctx, interval+time.Second)
        err := pc.pushMetrics(ctxLocal, writeMetrics)

В свою чергу pushMetrics() створює буфер bytes.Buffer, передає його до writeMetrics(), writeMetrics() викликає WritePrometheus(), яка отримує цей буфер:

func (pc *pushContext) pushMetrics(ctx context.Context, writeMetrics func(w io.Writer)) error {
  bb := getBytesBuffer()
  defer putBytesBuffer(bb)

  writeMetrics(bb)
...

І потім WritePrometheus() записує зібрані метрики в цей буфер:

// WritePrometheus writes all the metrics from s to w in Prometheus format.
func (s *Set) WritePrometheus(w io.Writer) {
...

А далі з цього буферу (все ще в pushMetrics() створюється request body, задаються headers:

І потім виконується відправка даних до переданого URL:

 

Тепер повернемось до “WritePrometheus() записує зібрані метрики в цей буфер“.

WritePrometheus() – це метод структури Set:

func (s *Set) WritePrometheus(w io.Writer) {
  ...
}

А Set створюється, коли ми викликаємо NetGauge():

func NewGauge(name string, f func() float64) *Gauge {
  return defaultSet.NewGauge(name, f)
}

defaultSet – це виклик NewSet():

var defaultSet = NewSet()

А NewSet() заповнює структуру Set:

// NewSet creates new set of metrics.
//
// Pass the set to RegisterSet() function in order to export its metrics via global WritePrometheus() call.
func NewSet() *Set {
  return &Set{
    m: make(map[string]*namedMetric),
  }
}

Тобто, при виклику NetGauge() ми передаємо аргумент з іменем метрики, NetGauge() викликає NewSet(), передає цю метрику, а NewSet() виконує ініціалізацію структури Set, в поле namedMetric задаючи нашу метрику.

Функція PushMetrics()

Ну а з PushMetrics() все майже аналогічно – створюється writeMetrics, викликається PushMetricsExt():

func PushMetrics(ctx context.Context, pushURL string, pushProcessMetrics bool, opts *PushOptions) error {
  writeMetrics := func(w io.Writer) {
    WritePrometheus(w, pushProcessMetrics)
  }
  return PushMetricsExt(ctx, pushURL, writeMetrics, opts)
}

А PushMetricsExt() викликає pushMetrics(), але тільки один раз, а не в циклі:

func PushMetricsExt(ctx context.Context, pushURL string, writeMetrics func(w io.Writer), opts *PushOptions) error {
  pc, err := newPushContext(pushURL, opts)
  if err != nil {
    return err
  }
  return pc.pushMetrics(ctx, writeMetrics)
}

Окей – повертаємось до нашого коду.

Отже, що нам треба зробити зараз – це замість WritePrometheus() викликати PushMetrics().

Створення context та виклик PushMetrics()

Для PushMetrics() потрібно передати context, який керує goroutines і завершує їх або по таймауту, або якщо сама програма отримала від системи сигнали SIGTERM чи SIGKILL.

Детальніше про context трохи далі, поки просто додаємо import "context", в main() створюємо пустий контекст з Background():

...
import (
  "context"
...
func main() {
        ...
  // will be populated with key:value pairs:
  // 'proj_123' = 'kraken_production'
  projectNames := make(map[string]string)

  ctx := context.Background()
...

В нашій функції fetchAndPush() додаємо параметр з типом context.Context:

...
func fetchAndPush(ctx context.Context, ...) {
   ...
}

Додаємо передачу контексту до виклику fetchAndPush():

...
       fetchAndPush(ctx, client, costsRes, projectsRes, projectNames)
...

Задаємо змінну з URL VictoriaMetrics, міняємо metrics.WritePrometheus() на metrics.PushMetrics(), до якої передаємо отриманий з main() context:

...

  //metrics.WritePrometheus(os.Stdout, false)
  pushURL := "http://localhost:8428/api/v1/import/prometheus"

  if err := metrics.PushMetrics(ctx, pushURL, false, nil); err != nil {
    panic(err)
  }
}

В принципі майже останнє, що залишилось – це виконувати нашу функцію з якимось інтервалом.

gocron – запуск задач за розкладом

Є приємний пакет gocron, додаємо його, поки тестуємо – ставимо запуск кожну хвилину:

import (
        ...
  "github.com/go-co-op/gocron"
        ...
)

...
func main() {
  s := gocron.NewScheduler(time.Local)

  s.Every(1).Minute().Do(func() {
    fetchAndPush(client, costsRes, projectsRes, projectNames)
  })

  s.StartBlocking()
}

Потім можна переробити на виклик раз на годину – s.Every(1).Hour().Do( ... ), або на початку кожної години – s.Cron("0 * * * *").Do( ... ).

І в кінці запускаємо крон зі StartBlocking(), який блокує завершення самої функції main().

Відкриваємо доступ до VictoriaMetrics в Kubernetes:

$ kk -n ops-monitoring-ns port-forward svc/vmsingle-vm-k8s-stack 8428

Запускаємо наш експортер:

$ go run main.go
test_openai_stats{type="costs", project="assistant_test_eval"} 6.501765299999999
test_openai_stats{type="costs", project="default_project"} 0.6592560500000001
test_openai_stats{type="costs", project="knowledge_base"} 2.17411225
test_openai_stats{type="costs", project="kraken_production"} 0.6471627999999999
^Csignal: interrupt

І перевіряємо дані вже у VictoriaMetrics:

Правда, тут з’явився якийсь “unknown” проект, треба буде додати логування.

Що ще треба поправити:

  • зараз ініціалізація структур CostsResponseData та ProjectsResponse виконується в main(), і потім при кожному виклику fetchAndPush() в них записуються дані
    • якщо проект видалиться з OpenAI – він залишиться в структурах, і ми будемо продовжувати писати метрики для проекту, якого вже нема
    • треба винести в саму fetchAndPush() і просто кожного разу заповнювати їх з нуля
  • аналогічно з projectNames – перенести ініціалізацію в саму fetchAndPush()
  • SetQueryParams – зараз передається однаково для обох викликів getOpenAi(), але в /organization/projects нема параметра group_by
  • в метриці лейблу type="" краще замінити на category=""
  • додати external lablels – щось типу “job="openai-exporter"
  • замість використання panic(err) – записувати в лог, повертати помилку до викликаючої функції і обробляти там
  • додати коректну обробку сигналів SIGTERM та SIGINT
  • resty.client вміє виконувати retry при помилках, треба додати SetRetryCount() і SetRetryWaitTime()
  • ну і додати логи виконання і помилок

Створення Golang context

Під час роботи в нашому коді запускається кілька одночасних операцій – з gocron.NewScheduler() ми запускаємо виконання нашої функції fetchAndPush(), в ній у нас запускаються HTTP-запити з resty.Client.Get(), у VictoriaMetrics запускаються виклики для запису до VictoriaMetrics endpoint.

Аби все це діло коректно завершити, а не просто “вбити” під час отримання SIGINT або SIGTERM – Go дозволяє нам керувати процесом завершення наших функцій і goroutines через context виконання.

Інший приклад, коли нам треба керувати виконанням операції – це задати ліміт на час виконання, як це, наприклад, зроблено в VictoriaMetrics у функції InitPushExtWithOptions():

...
  go func() {
    ticker := time.NewTicker(interval)
    defer ticker.Stop()
    stopCh := ctx.Done()
    for {
      select {
      case <-ticker.C:
        ctxLocal, cancel := context.WithTimeout(ctx, interval+time.Second)
        err := pc.pushMetrics(ctxLocal, writeMetrics)
...

Тут виконання pc.pushMetrics() обмежено interval, який передається при виклику InitPush().

При цьому context виконання включає в себе не тільки обробку сигналів і керування життєвим циклом функцій і goroutine, но і всю пов’язану з цим виконанням інформацію:

Package context defines the Context type, which carries deadlines, cancellation signals, and other request-scoped values across API boundaries and between processes

Я виніс описання роботи context окремою частиною, бо дуже цікавий механізм, а зараз просто давайте його додамо в наш код.

Отже, що нам треба:

  • створити context
  • створити “перехоплювач сигналів” SIGINT (Ctrl+C) та SIGTERM (сигнал від операційної системи, коли виконання програми завершується, наприклад – коли kubelet зупиняє контейнер)
  • відправити сигнал зупинки всім дочірнім функціям і goroutines
  • завершити виконання main()

Для цього замість виклику context.Background() в main() використовуємо signal.NotifyContext(), який отримує потрібні системні виклики і відправляє сигнал про зупинку всім пов’язаним задачам (як саме – див. далі у Bonus: як працює контроль виконання в context):

...
  rootCtx, rootCancel := signal.NotifyContext(
    context.Background(),
    os.Interrupt,
    syscall.SIGTERM,
  )
  defer rootCancel()
...

Далі описуємо запуск gocron.NewScheduler(), а в кінці main() запускаємо створення та читання з каналу:

...
  // block until Ctrl+C cancels rootCtx
  <-rootCtx.Done()
}

Як тільки NotifyContext() отримає SIGTERM – він закриє канал rootCtx.Done(), після чого каскадно закриються канали всіх дочірніх контекстів, потім всі дочірні goroutines, що слухають ці контексти, завершать роботу, і main() зможе коректно завершитись.

resty.client теж вміє працювати з context через SetContext(), йому передаємо наш rootCtx при виклику if err := getOpenAI(ctx, ... ) {...}.

Фінальний результат

Після всіх правок весь код експортеру тепер виглядає так:

package main

import (
  "context"
  "fmt"
  "log"
  "os"
  "os/signal"
  "strconv"
  "strings"
  "syscall"
  "time"

  "github.com/VictoriaMetrics/metrics"
  "github.com/go-co-op/gocron"
  "github.com/go-resty/resty/v2"
)

const (
  // base URL of the OpenAI Admin API
  baseURL = "https://api.openai.com/v1"

  // endpoints that we call
  costsPath    = "/organization/costs"
  projectsPath = "/organization/projects"

  // VictoriaMetrics push endpoint (Prometheus remote write format)
  pushURL = "http://localhost:8428/api/v1/import/prometheus"
)

// structure describing the JSON for costs API
// resty will unmarshal into this struct automatically
type CostsResponseData struct {
  Data []struct {
    Results []struct {
      ProjectID string `json:"project_id"`
      Amount    struct {
        Value float64 `json:"value"`
      } `json:"amount"`
    } `json:"results"`
  } `json:"data"`
}

// structure describing the JSON for projects API
// used to map project_id → readable project name
type ProjectsResponse struct {
  Data []struct {
    ID   string `json:"id"`
    Name string `json:"name"`
  } `json:"data"`
}

// normalizeLabel converts a project name into a Prometheus-safe label
// - lowercases
// - replaces spaces with underscores
// - replaces slashes to avoid label parser issues
func normalizeLabel(s string) string {
  s = strings.ToLower(s)
  s = strings.ReplaceAll(s, " ", "_")
  s = strings.ReplaceAll(s, "/", "_")
  return s
}

// getOpenAI performs a GET request to the OpenAI Admin API
// and unmarshals the returned JSON into the 'out' structure.
//
// ctx: allows cancellation (we pass rootCtx so Ctrl+C cancels requests)
// client: the resty client with authentication
// path: "/organization/costs" or "/organization/projects"
// params: optional query parameters
func getOpenAI(ctx context.Context, client *resty.Client, path string, params map[string]string, out any) error {
  // create HTTP request object
  req := client.R().
    SetContext(ctx). // attach context so cancellation works
    SetResult(out)   // register target structure for unmarshalling JSON

  // set optional query parameters
  if params != nil {
    req.SetQueryParams(params)
  }

  // execute HTTP GET request
  if _, err := req.Get(baseURL + path); err != nil {
    return fmt.Errorf("request to %s failed: %w", path, err)
  }

  return nil
}

// fetchAndPush performs one exporter cycle:
//
// 1. fetch costs grouped by project_id
// 2. fetch readable project names
// 3. build project_id → normalized_name map
// 4. create/update Prometheus gauges
// 5. push all metrics to VictoriaMetrics
//
// ctx: the root context (cancelled when Ctrl+C is pressed)
func fetchAndPush(ctx context.Context, client *resty.Client) error {
  // create fresh response holders for every iteration
  costsRes := &CostsResponseData{}
  projectsRes := &ProjectsResponse{}
  projectNames := make(map[string]string)

  // build query parameters for costs API
  // start_time: current timestamp (Unix)
  // group_by: instruct API to group costs per project_id
  timeNow := strconv.FormatInt(time.Now().Unix(), 10)
  costParams := map[string]string{
    "start_time": timeNow,
    "group_by":   "project_id",
  }

  // fetch costs data
  if err := getOpenAI(ctx, client, costsPath, costParams, costsRes); err != nil {
    return fmt.Errorf("fetch costs: %w", err)
  }

  // fetch project definitions
  if err := getOpenAI(ctx, client, projectsPath, nil, projectsRes); err != nil {
    return fmt.Errorf("fetch projects: %w", err)
  }

  // fill map: project_id → normalized_label
  for _, p := range projectsRes.Data {
    projectNames[p.ID] = normalizeLabel(p.Name)
  }

  // process returned costs
  for _, dataItem := range costsRes.Data {
    for _, result := range dataItem.Results {
      id := result.ProjectID
      amount := result.Amount.Value

      // resolve project readable name
      project := projectNames[id]
      if project == "" {
        project = "unknown"
      }

      metricName := fmt.Sprintf(
        `openai_stats{project="%s",category="costs"}`,
        project,
      )

      // get or create gauge
      gauge := metrics.GetOrCreateGauge(metricName, nil)

      // update gauge value
      gauge.Set(amount)

      // log written metric
      log.Printf("metric updated: name=%s value=%f", metricName, amount)
    }
  }

  // push metrics with job="openai_exporter"
  pushOpts := &metrics.PushOptions{
    ExtraLabels: `job="openai_exporter"`,
  }

  // push all collected metrics
  if err := metrics.PushMetrics(ctx, pushURL, false, pushOpts); err != nil {
    return fmt.Errorf("push metrics: %w", err)
  }

  return nil
}

func main() {
  // create a context that automatically cancels on OS signals (Ctrl+C, kill, SIGTERM)
  //
  // how it works:
  // - signal.NotifyContext wraps the parent context and subscribes it to OS signals
  // - when the program receives Ctrl+C (SIGINT) or SIGTERM:
  //       Go internally calls rootCancel()
  //       the context's Done() channel is closed
  // - all goroutines waiting on <-rootCtx.Done() are instantly unblocked
  // - any operation bound to this context (HTTP requests, timeouts, jobs)
  //       receives ctx.Err()==context.Canceled and stops gracefully
  //
  // practically:
  // - main goroutine waits for <-rootCtx.Done()
  // - when Ctrl+C arrives => rootCtx.Done() closes => program starts graceful shutdown
  //
  // 'defer rootCancel()' is used to clean up internal signal resources when main() exits normally
  rootCtx, rootCancel := signal.NotifyContext(
    context.Background(),
    os.Interrupt,
    syscall.SIGTERM,
  )
  defer rootCancel()

  // load OpenAI admin API key
  apiKey := os.Getenv("OPENAI_ADMIN_KEY")
  if apiKey == "" {
    log.Fatal("OPENAI_ADMIN_KEY is not set")
  }

  // create resty client with:
  // - bearer token
  // - automatic retries (3 attempts)
  client := resty.New().
    SetAuthToken(apiKey).
    SetRetryCount(3).
    SetRetryWaitTime(2 * time.Second)

  // create scheduler using local timezone
  s := gocron.NewScheduler(time.Local)

  // register a job that runs every 1 minute
  s.Every(1).Minute().Do(func() {
    start := time.Now()

    log.Println("starting fetch-and-push cycle")

    // run our exporter cycle
    if err := fetchAndPush(rootCtx, client); err != nil {
      log.Println("ERROR during fetchAndPush:", err)
      return
    }

    log.Println("fetch-and-push completed in", time.Since(start))
  })

  log.Println("starting scheduler...")

  // run scheduler in background goroutine
  s.StartAsync()

  // block until Ctrl+C cancels rootCtx
  <-rootCtx.Done()

  log.Println("received Ctrl+C, stopping scheduler...")

  // shutdown scheduler gracefully
  s.Stop()

  log.Println("scheduler stopped, exiting")
}

Перевіряємо у VictoriaMetrics:

І порівняємо з даними в самому OpenAI на сторінці platform.openai.com/settings/organization/usage:

Ті самі 6.95 долари, що ми бачимо у VictoriaMetrics від нашого експортеру.

Можна б ще покращити код, наприклад розбити велику функцію fetchAndPush(), і треба додати передачу URL до VictoriaMetrics зі змінних оточення, але поки поживемо з таким варіантом.

Bonus: як працює контроль виконання через Golang context

Ми в нашій функції fetchAndPush() використовуємо metrics.PushMetrics(), передавши йому контекст.

Але для кращої картини – давайте знову повернемося до InitPush(), бо там використання context більш явне.

Отже, InitPush() викликає InitPushExt(), а InitPushExt() викликає InitPushExtWithOptions(), якому передає пустий context.Background()return InitPushExtWithOptions(context.Background() ...).

В InitPushExtWithOptions() запускається goroutine, go func() {}, в якій створюється локальний context :

...
        ctxLocal, cancel := context.WithTimeout(ctx, interval+time.Second)
        err := pc.pushMetrics(ctxLocal, writeMetrics)
...

Закриття каналу з cancel()

При виклику функції context.WithTimeout() відбувається наступний процес:

  • WithTimeout() викликає WithDeadline()
  • WithDeadline() викликає WithDeadlineCause():
    • де створюється об’єкт структури c := &timerCtx{}
      • структура timerCtx містить в собі embedding структуру cancelCtx
      • таким чином timerCtx тепер має доступ до всіх методів структури cancelCtx
    • далі WithDeadlineCause() перевіряє умову if dur <= 0 і, і якщо час виконання завершився, то:
      • викликає c.cancel(true, DeadlineExceeded, cause)
      • повертає “return c, func() { c.cancel(false, Canceled, nil) }“, яка повертається до InitPushExtWithOptions() в частині ctxLocal, cancel := context.WithTimeout() і "func() { c.cancel() }" і стає cancel()
        • c.cancel() – це метод структури timerCtxfunc (c *timerCtx) cancel(), який викликає c.cancelCtx.cancel()
        • а c.cancelCtx.cancel() – це метод структури cancelCtxfunc (c *cancelCtx) cancel(), який викликає d, _ := c.done.Load().(chan struct{})
          • і викликає close(d)

Ось тут:

func (c *cancelCtx) cancel(removeFromParent bool, err, cause error) {
...
  d, _ := c.done.Load().(chan struct{})
  if d == nil {
    ...
  } else {
    close(d)
  }
...

c.done.Load() – викликається з поля done структури cancelCtx:

type cancelCtx struct {
        ...
  done     atomic.Value          // of chan struct{}, created lazily, closed by first cancel call
        ...
}

Де Load() це метод структури Value:

func (v *Value) Load() (val any)

Тобто, в d, _ := c.done.Load().(chan struct{}) викликається Load(), в (chan struct{}) виконується type assertion, тобто перевіряється, що це тип chan struct{}, після чого d стає chan struct{}, після чого виконується close(channel).

А close() – це вбудована функція Go, яка закриває отриманий аргументом канал.

Як тільки канал Done() закривається – всі goroutines, які виконують <-ctx.Done(), миттєво пробуджуються і можуть коректно завершити свою роботу.

В InitPushExtWithOptions() це виконується тут:

go func() {
    ...
    stopCh := ctx.Done()
    ...
    case <-stopCh:
                              ...
      return
    }
  }
}()

Закриття каналу – це читання нульового значення, що призводить до спрацювання умови case => що призводить до завершення циклу через виклик return => що призводить до завершення всієї go func() {}.

Окей.

А звідки канал взявся?

Відкриття каналу

Для того, аби функція чи рутина постійно “слухали” цей канал в очікуванні його закриття – ми викликаємо Done():

...
  go func() {
    ticker := time.NewTicker(interval)
    defer ticker.Stop()
    stopCh := ctx.Done()
      ...
      case <-stopCh:
        if wg != nil {
          wg.Done()
        }
        return
      }
...

А ctx.Done() – це метод інтерфейсу Context interface:

type Context interface {
...
  Done() <-chan struct{}
}

В якій і створюється сам канал:

...
func (c *cancelCtx) Done() <-chan struct{} {
        ...
  if d == nil {
    d = make(chan struct{})
    c.done.Store(d)
  }
  return d.(chan struct{})
}
...

Отже, коли викликається:

  1. context.WithTimeout()  =>
    1. WithDeadline() =>
      1. WithDeadlineCause() =>
        1. c := &timerCtx{} яка має cancelCtx
          1. а cancelCtx{} має метод Done()

І коли ми викликаємо

...
  rootCtx, rootCancel := signal.NotifyContext(
    context.Background(),
    os.Interrupt,
    syscall.SIGTERM,
  )
...

То в signal.NotifyContext() ми передаємо пустий контекст, а signal.NotifyContext() створює і повертає власний контекст з context.WithCancel():

...
func NotifyContext(parent context.Context, signals ...os.Signal) (ctx context.Context, stop context.CancelFunc) {
  ctx, cancel := context.WithCancel(parent)
  c := &signalCtx{
           ...
  }
        ...
  return c, c.stop
}
...

Тому в кінці нашої main() ми можемо викликати читання з каналу:

...
  // block until Ctrl+C cancels rootCtx
  <-rootCtx.Done()
...

А як тільки канал закриється – управління повертається до main(), виконується gocron.Stop(), і виконання програми завершується.