<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Arseny Zinchenko</title>
    <description>The latest articles on DEV Community by Arseny Zinchenko (@setevoy).</description>
    <link>https://dev.to/setevoy</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F33441%2F407b2263-6b99-44d0-8630-be9cbd51a255.jpg</url>
      <title>DEV Community: Arseny Zinchenko</title>
      <link>https://dev.to/setevoy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/setevoy"/>
    <language>en</language>
    <item>
      <title>AWS: ALB and Cloudflare - Configuring mTLS and AWS Security Rules</title>
      <dc:creator>Arseny Zinchenko</dc:creator>
      <pubDate>Thu, 18 Jun 2026 08:23:32 +0000</pubDate>
      <link>https://dev.to/aws-builders/aws-alb-and-cloudflare-configuring-mtls-and-aws-security-rules-4mi</link>
      <guid>https://dev.to/aws-builders/aws-alb-and-cloudflare-configuring-mtls-and-aws-security-rules-4mi</guid>
      <description>&lt;h3&gt;
  
  
  AWS: ALB and Cloudflare - Configuring mTLS and AWS Security Rules
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fylkkvuigbc1iwzggi2k1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fylkkvuigbc1iwzggi2k1.png" width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;While preparing the infrastructure for migrating RTFM from the DigitalOcean server to AWS (see &lt;a href="https://rtfm.co.ua/en/aws-basic-infrastructure-setup-for-wordpress/" rel="noopener noreferrer"&gt;AWS: basic infrastructure setup for WordPress&lt;/a&gt;) I decided to also try &lt;a href="https://docs.aws.amazon.com/elasticloadbalancing/latest/application/mutual-authentication.html" rel="noopener noreferrer"&gt;AWS ALB mutual authentication&lt;/a&gt; (for some reason I thought this feature launched at the last re:Invent, at the end of 2024, but it’s actually been around since late 2023 - see &lt;a href="https://aws.amazon.com/blogs/aws/mutual-authentication-for-application-load-balancer-to-reliably-verify-certificate-based-client-identities/" rel="noopener noreferrer"&gt;Mutual authentication for Application Load Balancer reliably verifies certificate-based client identities&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Contents
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/aws-alb-and-cloudflare-configuring-mtls-and-aws-security-rules/#AWS_ALB_cost_and_LCU" rel="noopener noreferrer"&gt;AWS ALB cost and LCU&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/aws-alb-and-cloudflare-configuring-mtls-and-aws-security-rules/#AWS_Load_Balancer_mTLS_use_cases" rel="noopener noreferrer"&gt;AWS Load Balancer mTLS use cases&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/aws-alb-and-cloudflare-configuring-mtls-and-aws-security-rules/#Configuring_ALB_mutual_TLS_with_Cloudflare" rel="noopener noreferrer"&gt;Configuring ALB mutual TLS with Cloudflare&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/aws-alb-and-cloudflare-configuring-mtls-and-aws-security-rules/#Configuring_Cloudflare" rel="noopener noreferrer"&gt;Configuring Cloudflare&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/aws-alb-and-cloudflare-configuring-mtls-and-aws-security-rules/#Adding_a_new_domain_to_Cloudflare" rel="noopener noreferrer"&gt;Adding a new domain to Cloudflare&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/aws-alb-and-cloudflare-configuring-mtls-and-aws-security-rules/#Configuring_the_Application_Load_Balancer" rel="noopener noreferrer"&gt;Configuring the Application Load Balancer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/aws-alb-and-cloudflare-configuring-mtls-and-aws-security-rules/#Getting_the_Cloudflare_Certificate_Authority" rel="noopener noreferrer"&gt;Getting the Cloudflare Certificate Authority&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/aws-alb-and-cloudflare-configuring-mtls-and-aws-security-rules/#Creating_a_Trust_Store" rel="noopener noreferrer"&gt;Creating a Trust Store&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/aws-alb-and-cloudflare-configuring-mtls-and-aws-security-rules/#Configuring_mTLS_for_the_ALB_Listener" rel="noopener noreferrer"&gt;Configuring mTLS for the ALB Listener&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/aws-alb-and-cloudflare-configuring-mtls-and-aws-security-rules/#Protection_with_AWS_Security_Group_and_Cloudflare_IP_ranges" rel="noopener noreferrer"&gt;Protection with AWS Security Group and Cloudflare IP ranges&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/aws-alb-and-cloudflare-configuring-mtls-and-aws-security-rules/#Creating_an_IAM_Role_for_the_EC2_Instance_profile" rel="noopener noreferrer"&gt;Creating an IAM Role for the EC2 Instance profile&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/aws-alb-and-cloudflare-configuring-mtls-and-aws-security-rules/#Shell_script_for_updating_the_ALB_Security_Group" rel="noopener noreferrer"&gt;Shell script for updating the ALB Security Group&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/aws-alb-and-cloudflare-configuring-mtls-and-aws-security-rules/#Replacing_the_Security_Group_on_the_ALB" rel="noopener noreferrer"&gt;Replacing the Security Group on the ALB&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/aws-alb-and-cloudflare-configuring-mtls-and-aws-security-rules/#Running_from_crontab" rel="noopener noreferrer"&gt;Running from crontab&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The idea is to allow connections to the ALB only from clients that pass authentication, and in the Cloudflare+AWS ALB case, Cloudflare will sign all its requests with a TLS certificate (see &lt;a href="https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/" rel="noopener noreferrer"&gt;Cloudflare Authenticated Origin Pulls&lt;/a&gt;), and AWS ALB will verify them. - This way, access to the Load Balancer URL will only be possible for Cloudflare.&lt;/p&gt;

&lt;p&gt;The inspiration for this idea came from the recent story about the &lt;a href="https://rtfm.co.ua/en/tcp-ip-syn-flood-attack-on-the-rtfm-server-and-hacker-news-hug-of-death/" rel="noopener noreferrer"&gt;TCP/IP: SYN flood attack on the RTFM server and Hacker News hug of death&lt;/a&gt; - when TCP connections were being opened directly to port 443 on the DigitalOcean server, bypassing Cloudflare.&lt;/p&gt;

&lt;p&gt;In addition to mTLS I later decided to also add IP restrictions - configured ALB access only for Cloudflare addresses defined in AWS Security Rules, because mTLS is about authentication, while Security Rules are about network-level protection.&lt;/p&gt;

&lt;p&gt;Although initially I was planning to use pure mTLS - it was interesting to see how it works in ALB, and I was too lazy to build automation for updating AWS Security Rules.&lt;/p&gt;

&lt;p&gt;The example here was done on a test domain - so we’ll also add it to Cloudflare along the way, but both solutions are already working for &lt;a href="https://rtfm.co.ua/en/" rel="noopener noreferrer"&gt;rtfm.co.ua&lt;/a&gt; itself.&lt;/p&gt;

&lt;p&gt;Why restrict access to the AWS Application Load Balancer in the first place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cloudflare has many Security rules that block unwanted traffic - if requests go directly to the ALB, they’ll reach NGINX, and then some rules need to be duplicated there as well (which was my situation)&lt;/li&gt;
&lt;li&gt;cost during DDoS: when the blog lived on a DigitalOcean server with just a public IP for the droplet, requests to that IP didn’t affect cost (only extra traffic), but in AWS the number of connections to an ALB affects cost&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;P.S. And once again you try to write concisely - and end up with a lot of text :-(&lt;/p&gt;

&lt;p&gt;See also related posts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/aws-basic-infrastructure-setup-for-wordpress/" rel="noopener noreferrer"&gt;AWS: Basic Infrastructure Setup for WordPress&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/aws-self-managed-ec2-nat-gateway-vs-aws-managed-nat/" rel="noopener noreferrer"&gt;AWS: Self-Managed EC2 NAT Gateway vs AWS Managed NAT&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/aws-amazon-linux-sending-email-with-postfix-via-gmail/" rel="noopener noreferrer"&gt;AWS: Amazon Linux - Sending Email with Postfix via Gmail&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/victoriametrics-basic-monitoring-for-aws-linux-nginx-and-php/" rel="noopener noreferrer"&gt;VictoriaMetrics: Basic Monitoring for AWS, Linux, NGINX, and PHP&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  AWS ALB cost and LCU
&lt;/h3&gt;

&lt;p&gt;The cost of an AWS Load Balancer includes an hourly charge for the instance itself, the standard Data Transfer Charge, charges for allocated Public IPs, and separately - &lt;strong&gt;Load Balancer Capacity Units&lt;/strong&gt; (LCU).&lt;/p&gt;

&lt;p&gt;For Network Load Balancer there’s its own unit - Network Load Balancer Capacity Unit (NLCU), and for Gateway Load Balancer, accordingly, GLCU.&lt;/p&gt;

&lt;p&gt;See &lt;a href="https://aws.amazon.com/elasticloadbalancing/pricing/" rel="noopener noreferrer"&gt;Elastic Load Balancing pricing&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;LCU is not a very intuitive unit, so a few words about it.&lt;/p&gt;

&lt;p&gt;1 LCU equals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;New connections&lt;/strong&gt; : new connections to the ALB - 25 connections per second consume 1 LCU&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Active connections&lt;/strong&gt; : every 3,000 active connections per minute (or 1,500 when using mTLS)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Processed bytes&lt;/strong&gt; : 1 LCU covers 1 GB of traffic per hour (or 0.4 GB for Lambda targets) - counted as ingress + egress combined&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rule evaluations&lt;/strong&gt; : checking 1,000 rules per second is one LCU&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rule evaluations refer to Listener rules:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fck8u1qd6o6y82dw954ke.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fck8u1qd6o6y82dw954ke.png" width="800" height="389"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And if you have many rules like “&lt;code&gt;IF path is /api/* THEN forward to target-group-api&lt;/code&gt;" - you pay for each evaluation for every new HTTP request.&lt;/p&gt;

&lt;p&gt;When determining which of the above parameters to use for LCU billing, AWS takes whichever one hits its threshold first: meaning as soon as you have 26 requests per second, even if &lt;strong&gt;Processed bytes&lt;/strong&gt; were only 10 megabytes - you’ll be charged 1 LCU based on &lt;strong&gt;New connections&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So in the event of a DDoS (and RTFM has had a few, though not severe ones) - we can easily rack up unexpected costs.&lt;/p&gt;

&lt;p&gt;There’s a calculator - &lt;a href="https://exampleloadbalancer.com/ondemand_capacity_reservation_calculator.html" rel="noopener noreferrer"&gt;Load Balancer Capacity Unit Reservation Calculator&lt;/a&gt; - where you can estimate how much LCU a given load will cost.&lt;/p&gt;

&lt;h3&gt;
  
  
  AWS Load Balancer mTLS use cases
&lt;/h3&gt;

&lt;p&gt;Actually, if you’re using ALB access restrictions via a Security Group with a list of only allowed IPs - then mTLS isn’t needed at all, because you’re already cutting connections at the AWS network level, before the request even reaches the Load Balancer.&lt;/p&gt;

&lt;p&gt;What can genuinely be useful:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;service-to-service authentication:&lt;/li&gt;
&lt;li&gt;for example, we have an Internal ALB behind which various monitoring services live, like VictoriaLogs&lt;/li&gt;
&lt;li&gt;logs may contain sensitive information like tokens or even passwords, so access to this endpoint needs to be restricted&lt;/li&gt;
&lt;li&gt;VMAuth with password authentication is good, but mTLS adds another layer of protection (see &lt;a href="https://rtfm.co.ua/victoriametrics-vmauth-proksi-autentifiikaciya-ta-avtorizaciya/" rel="noopener noreferrer"&gt;VictoriaMetrics: VMAuth - proxy, authentication and authorization&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;mobile client authentication to an API:&lt;/li&gt;
&lt;li&gt;certificates are embedded in the clients (certificate pinning), which they use to access the API&lt;/li&gt;
&lt;li&gt;but keep in mind potential issues with certificate rotation - either in case of compromise or simply when the certificate expires&lt;/li&gt;
&lt;li&gt;IoT: we don’t have that (yet), but in general - a very useful use case&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Configuring ALB mutual TLS with Cloudflare
&lt;/h3&gt;

&lt;p&gt;First let’s look at the mTLS access restriction option, since it’s an interesting and useful feature.&lt;/p&gt;

&lt;p&gt;Then we’ll add the AWS Security Rules approach with automated rule updates.&lt;/p&gt;

&lt;h3&gt;
  
  
  Configuring Cloudflare
&lt;/h3&gt;

&lt;p&gt;This post was written before the RTFM migration, using a test domain, so let’s quickly go through the process of adding and configuring a domain in Cloudflare.&lt;/p&gt;

&lt;h3&gt;
  
  
  Adding a new domain to Cloudflare
&lt;/h3&gt;

&lt;p&gt;Go to Domains, and click Onboard a domain:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5kk33l5zwfd7h885smq1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5kk33l5zwfd7h885smq1.png" width="603" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Enter the name, the rest of the settings can be left at defaults:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwvcbgkxd33vf9fnnpa4g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwvcbgkxd33vf9fnnpa4g.png" width="731" height="803"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When importing records from AWS DNS, it missed a few Let’s Encrypt validation records, but that’s fine for now:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzne1mkbnhlvo1j6qjuj3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzne1mkbnhlvo1j6qjuj3.png" width="800" height="977"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Get the list of new Name Servers:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhr6vfo41k15ww5jyk97m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhr6vfo41k15ww5jyk97m.png" width="800" height="819"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Update them at the domain registrar:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fovqu1qtyrucejbdus643.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fovqu1qtyrucejbdus643.png" width="800" height="539"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Wait for the update - time depends on the DNS zone administrator:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7mnbjwvg4m2683g3ihw4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7mnbjwvg4m2683g3ihw4.png" width="800" height="657"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For the kiev.ua DNS zone this took about half an hour:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffl3vr3q2ewmyox61fmtx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffl3vr3q2ewmyox61fmtx.png" width="794" height="302"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Go to SSL/TLS and enable the &lt;a href="https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/" rel="noopener noreferrer"&gt;Authenticated Origin Pulls&lt;/a&gt; option.&lt;/p&gt;

&lt;p&gt;Once we enable this option, Cloudflare will start attaching its client certificate to every new request to the origin - in our case, AWS ALB.&lt;/p&gt;

&lt;p&gt;I really wanted to go into more detail about SSL/TLS handshake and keys/certificates here - but decided it would be out of scope for this post, especially since I once worked through the details in the post &lt;a href="https://rtfm.co.ua/ru/what-is-ssl-tls-v-detalyax/" rel="noopener noreferrer"&gt;What is: SSL/TLS in detail&lt;/a&gt; (in Rus).&lt;/p&gt;

&lt;p&gt;Although I might write a new post on the topic, especially now that TLS 1.3 is current, while my post covered TLS 1.2, which was relevant at the time.&lt;/p&gt;

&lt;p&gt;Or you can read the already-mentioned &lt;a href="https://aws.amazon.com/blogs/networking-and-content-delivery/introducing-mtls-for-application-load-balancer/" rel="noopener noreferrer"&gt;Introducing mTLS for Application Load Balancer&lt;/a&gt; - it covers the basic TLS concepts pretty well.&lt;/p&gt;

&lt;p&gt;So Cloudflare just passes the client certificate, and from there it’s up to the origin what to do with it - and in the next step we’ll configure the ALB to verify it.&lt;/p&gt;

&lt;p&gt;For now just enable the option - it doesn’t cost extra (except that the LCU cost per Active connections will be 1,500 instead of 3,000), won’t add significant load to the ALB or extra traffic:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz2is4jwpwhl6icqg00vo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz2is4jwpwhl6icqg00vo.png" width="799" height="377"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Add a CNAME record for the domain with the value == ALB URL:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsp2pmp9w7m3wqm1qws80.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsp2pmp9w7m3wqm1qws80.png" width="800" height="465"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Verify that everything works now, because even though we enabled Authenticated Origin Pulls on Cloudflare, no verification is being performed on the ALB yet:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F408n5ozvbv6ao8a5kpdj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F408n5ozvbv6ao8a5kpdj.png" width="800" height="376"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And a request directly to the ALB URL still works, just with an SSL error:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyln8fcr72zkazto6iu5v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyln8fcr72zkazto6iu5v.png" width="633" height="235"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Configuring the Application Load Balancer
&lt;/h3&gt;

&lt;p&gt;Now we need to configure client certificate verification from Cloudflare on our ALB.&lt;/p&gt;

&lt;h3&gt;
  
  
  Getting the Cloudflare Certificate Authority
&lt;/h3&gt;

&lt;p&gt;The ALB will use the public &lt;a href="https://developers.cloudflare.com/ssl/reference/certificate-authorities/" rel="noopener noreferrer"&gt;Certificate Authority certificate from Cloudflare&lt;/a&gt; for verification - we need to add it to the Load Balancer’s Trust Store and then attach it to the ALB Listener.&lt;/p&gt;

&lt;p&gt;Note that using a Trust Store also costs money - “&lt;em&gt;$0.0056 per hour per Trust Store Associated with Application Load Balancer when using Mutual TLS&lt;/em&gt;”, which is ~$4 per month.&lt;/p&gt;

&lt;p&gt;Download the certificate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-o&lt;/span&gt; cloudflare-origin-pull-ca.pem https://developers.cloudflare.com/ssl/static/authenticated_origin_pull_ca.pem
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The certificate is shared across all of Cloudflare - though there’s an option to &lt;a href="https://developers.cloudflare.com/ssl/edge-certificates/custom-certificates/uploading/#upload-a-custom-certificate" rel="noopener noreferrer"&gt;upload your own&lt;/a&gt; and only pass verification with that one.&lt;/p&gt;

&lt;p&gt;But for the case where we just want to block random Chinese bots, the Cloudflare certificate itself is sufficient.&lt;/p&gt;

&lt;p&gt;Check the downloaded file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;file cloudflare-origin-pull-ca.pem
cloudflare-origin-pull-ca.pem: PEM certificate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or peek inside it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ openssl x509 -in cloudflare-origin-pull-ca.pem -text -noout
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number: 6310029703491235425 (0x5791ba9556c22e61)
        Signature Algorithm: sha512WithRSAEncryption
        Issuer: C=US, O=CloudFlare, Inc., OU=Origin Pull, L=San Francisco, ST=California, CN=origin-pull.cloudflare.net
        Validity
            Not Before: Oct 10 18:45:00 2019 GMT
            Not After : Nov 1 17:00:00 2029 GMT
        Subject: C=US, O=CloudFlare, Inc., OU=Origin Pull, L=San Francisco, ST=California, CN=origin-pull.cloudflare.net
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part here - &lt;strong&gt;Not After : Nov 1 17:00:00 2029&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you can enable the “Allow expired client certificates” option in the ALB mTLS settings&lt;/li&gt;
&lt;li&gt;or simply add the new certificate to the Trust Store and remove the old one at the end of 2029&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Creating a Trust Store
&lt;/h3&gt;

&lt;p&gt;To add the certificate to the ALB, we need to create a Trust Store, but certificates can only be added to a Trust Store from AWS S3, so first upload it to some bucket of yours:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhlmd5ytzlbnk4ccnhsx7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhlmd5ytzlbnk4ccnhsx7.png" width="799" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Go to EC2 &amp;gt; Load Balancing &amp;gt; Trust Stores, create a new one:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff0pmxoa4mutdqqwypat9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff0pmxoa4mutdqqwypat9.png" width="799" height="523"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Specify the path to the certificate file in S3:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftrwpzpnnbe7nkgt6r741.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftrwpzpnnbe7nkgt6r741.png" width="800" height="211"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Configuring mTLS for the ALB Listener
&lt;/h3&gt;

&lt;p&gt;Edit the HTTPS Listener - since mTLS logically happens there:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpzkr7tk9452xa56yyeo6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpzkr7tk9452xa56yyeo6.png" width="800" height="364"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Enable the mTLS option, and there are two sub-options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Passthrough&lt;/strong&gt; : pass the certificate to the targets in the Target Group - validation is done by the backend&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify with trust store&lt;/strong&gt; : perform verification on the ALB itself using the certificate from the Trust Store - our case&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Switch to “Verify with trust store”:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgzi2lj316szjfcgfg3nx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgzi2lj316szjfcgfg3nx.png" width="799" height="348"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now try a direct connection to the ALB URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-kI&lt;/span&gt; https://rtfm-alb-1984146384.eu-west-1.elb.amazonaws.com
curl: &lt;span class="o"&gt;(&lt;/span&gt;56&lt;span class="o"&gt;)&lt;/span&gt; Recv failure: Connection reset by peer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in the browser:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcsinbp380mpxn5so4ywr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcsinbp380mpxn5so4ywr.png" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But through Cloudflare everything works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; https://test-tls.setevoy.kiev.ua
HTTP/2 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fietgbrq3u0d9wdqfgnut.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fietgbrq3u0d9wdqfgnut.png" width="799" height="312"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And &lt;a href="https://rtfm.co.ua/en/" rel="noopener noreferrer"&gt;rtfm.co.ua&lt;/a&gt; itself is already on this ALB with mTLS enabled.&lt;/p&gt;

&lt;h3&gt;
  
  
  Protection with AWS Security Group and Cloudflare IP ranges
&lt;/h3&gt;

&lt;p&gt;But mTLS alone isn’t quite enough for full ALB protection:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;during a DDoS we’ll still be spending LCU on connections&lt;/li&gt;
&lt;li&gt;requests that don’t go through Cloudflare need to be filtered at the backend - in my case NGINX&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the most reliable approach is to restrict traffic at the AWS Security Group level attached to the Load Balancer: then no LCU will be spent at all, because packets won’t even reach the ALB.&lt;/p&gt;

&lt;p&gt;There are ready-made Terraform modules, for example - &lt;a href="https://registry.terraform.io/modules/orzarchi/cloudflare-security-group/aws/latest" rel="noopener noreferrer"&gt;cloudflare-security-group&lt;/a&gt;, but it only works with a single Security Group. There’s a more advanced option - &lt;a href="https://registry.terraform.io/modules/coresolutions-ltd/cloudflare-sg-updater/aws/latest" rel="noopener noreferrer"&gt;cloudflare-sg-updater&lt;/a&gt;, which finds Security Groups by tags.&lt;/p&gt;

&lt;p&gt;That said, personally I’m not a big fan of AWS Lambda, and at least for now I’m not using Terraform for RTFM, so I did it simply with a shell script using the AWS CLI.&lt;/p&gt;

&lt;p&gt;Plenty of recent practice for remembering how to write scripts - see &lt;a href="https://rtfm.co.ua/freebsd-home-nas-part-14-avtomatizaciya-bekapiv-skripti-rsync-rclone/" rel="noopener noreferrer"&gt;FreeBSD: Home NAS, part 15: backup automation - scripts, rsync, rclone&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The AWS CLI on Amazon Linux comes pre-installed, you just need to either configure an AWS profile or attach an &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2_instance-profiles.html" rel="noopener noreferrer"&gt;EC2 Instance Profile&lt;/a&gt; with an IAM policy that grants permission to modify the Security Group.&lt;/p&gt;

&lt;p&gt;One nuance to keep in mind if you’re writing the script yourself: IPs do change occasionally, even if rarely, and they need to be removed from the Security Group. But simply deleting all old entries and then adding new ones is not an option, because at that moment the ALB will lose its connection to Cloudflare.&lt;/p&gt;

&lt;p&gt;So the script performs a comparison of old and new addresses and only removes those that are no longer in the latest update from Cloudflare.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating an IAM Role for the EC2 Instance profile
&lt;/h3&gt;

&lt;p&gt;Eventually an IAM Role will need to be added to the instance anyway, so instead of using an AWS CLI profile with keys, it’s better to set up access to edit the Security Group through an EC2 Instance profile from the start.&lt;/p&gt;

&lt;p&gt;The role needs rights to run &lt;code&gt;ec2:DescribeSecurityGroups&lt;/code&gt;, &lt;code&gt;ec2:AuthorizeSecurityGroupIngress&lt;/code&gt;, and &lt;code&gt;ec2:RevokeSecurityGroupIngress&lt;/code&gt; - let's create a separate IAM Policy.&lt;/p&gt;

&lt;p&gt;Create a new Security Group for the ALB - leave the Inbound rules empty, and in Outbound - keep the default “All to All”:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F24bm1vjirfqtpu8dgd0j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F24bm1vjirfqtpu8dgd0j.png" width="800" height="678"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Create a new IAM Policy, restrict access to only this Security Group.&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;ec2:DescribeSecurityGroups&lt;/code&gt; you can't set a resource restriction since the rule is global for all SGs, so we describe it in two separate Statements - one with &lt;code&gt;Resource: "*"&lt;/code&gt;, and the second with the specific SG.&lt;/p&gt;

&lt;p&gt;Apply a &lt;code&gt;Resource&lt;/code&gt; restriction: specify access limited to a specific group, because the script will perform destructive actions - deleting rules, and for such actions it's always better to limit the blast radius in case "something goes wrong":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ec2:DescribeSecurityGroups"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="s2"&gt;"ec2:AuthorizeSecurityGroupIngress"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="s2"&gt;"ec2:RevokeSecurityGroupIngress"&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:ec2:eu-west-1:ACCOUNT_ID:security-group/sg-029ffc5f56be700ea"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4z952n24ml4zsmfk74pv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4z952n24ml4zsmfk74pv.png" width="800" height="408"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Set the policy name and save:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbc7elf8pnqq2j4hqjfft.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbc7elf8pnqq2j4hqjfft.png" width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Create an IAM Role, in Use Case select EC2:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fejrecr0vtxiug8xcka58.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fejrecr0vtxiug8xcka58.png" width="800" height="444"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Attach the policy created above:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpx3snnsbrk3njjkk5l84.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpx3snnsbrk3njjkk5l84.png" width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Set the role name and save (though the name chosen here isn’t ideal - because 100% more permissions will be added to this role down the line):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8dof2rmtfikd4gjnunn1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8dof2rmtfikd4gjnunn1.png" width="800" height="741"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In Security &amp;gt; Modify IAM Role, attach the role to the EC2 - can be done without stopping the instance:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzjhqtvi28m18y8q1wb95.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzjhqtvi28m18y8q1wb95.png" width="648" height="369"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Attach our new role:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm3rcx4fhhibk34qie8uf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm3rcx4fhhibk34qie8uf.png" width="798" height="159"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;SSH into the instance, and verify that it now has access to, for example, &lt;code&gt;ec2:DescribeSecurityGroups&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;ec&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="err"&gt;-user@ip&lt;/span&gt;&lt;span class="mi"&gt;-10-0-3-146&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;~&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;aws&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;ec&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;describe-security-groups&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;--group-ids&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;sg&lt;/span&gt;&lt;span class="mi"&gt;-029&lt;/span&gt;&lt;span class="err"&gt;ffc&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="err"&gt;f&lt;/span&gt;&lt;span class="mi"&gt;56&lt;/span&gt;&lt;span class="err"&gt;be&lt;/span&gt;&lt;span class="mi"&gt;700&lt;/span&gt;&lt;span class="err"&gt;ea&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"SecurityGroups"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"GroupId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sg-029ffc5f56be700ea"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"IpPermissionsEgress"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"IpProtocol"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"UserIdGroupPairs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"IpRanges"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
                        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                            &lt;/span&gt;&lt;span class="nl"&gt;"CidrIp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.0.0.0/0"&lt;/span&gt;&lt;span class="w"&gt;
                        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"Ipv6Ranges"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"PrefixListIds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Shell script for updating the ALB Security Group
&lt;/h3&gt;

&lt;p&gt;Add a script that will:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;fetch the current list of Cloudflare IP CIDRs (both IPv4 and IPv6)&lt;/li&gt;
&lt;li&gt;use the AWS CLI to get the current list of rules in the Security Group&lt;/li&gt;
&lt;li&gt;compare the new addresses with the Security Group values and add only those addresses not currently present&lt;/li&gt;
&lt;li&gt;same logic for removal - if an IP is no longer in Cloudflare’s current list, remove it from the Security Group&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There’s only one Security Group, so it’s simply set at the top in the &lt;code&gt;$SG_ID&lt;/code&gt; variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;

&lt;span class="c"&gt;# Update ALB security group with current Cloudflare IP ranges&lt;/span&gt;
&lt;span class="nv"&gt;SG_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"sg-029ffc5f56be700ea"&lt;/span&gt;
&lt;span class="nv"&gt;PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;443

&lt;span class="c"&gt;# Fetch current Cloudflare IP ranges&lt;/span&gt;
&lt;span class="nv"&gt;CF_IPS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://www.cloudflare.com/ips-v4&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://www.cloudflare.com/ips-v6&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Get current IPs from security group&lt;/span&gt;
&lt;span class="nv"&gt;CURRENT_IPS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws ec2 describe-security-groups &lt;span class="nt"&gt;--group-ids&lt;/span&gt; &lt;span class="nv"&gt;$SG_ID&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"SecurityGroups[0].IpPermissions[?FromPort==&lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="nv"&gt;$PORT&lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="s2"&gt;].[IpRanges[].CidrIp, Ipv6Ranges[].CidrIpv6]"&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Add missing IPs&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;IP &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$CF_IPS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CURRENT_IPS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Adding &lt;/span&gt;&lt;span class="nv"&gt;$IP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;":"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
            &lt;/span&gt;aws ec2 authorize-security-group-ingress &lt;span class="nt"&gt;--group-id&lt;/span&gt; &lt;span class="nv"&gt;$SG_ID&lt;/span&gt; &lt;span class="nt"&gt;--ip-permissions&lt;/span&gt; &lt;span class="s2"&gt;"[{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;IpProtocol&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;tcp&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;FromPort&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;$PORT&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;ToPort&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;$PORT&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;Ipv6Ranges&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:[{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;CidrIpv6&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$IP&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}]}]"&lt;/span&gt;
        &lt;span class="k"&gt;else
            &lt;/span&gt;aws ec2 authorize-security-group-ingress &lt;span class="nt"&gt;--group-id&lt;/span&gt; &lt;span class="nv"&gt;$SG_ID&lt;/span&gt; &lt;span class="nt"&gt;--protocol&lt;/span&gt; tcp &lt;span class="nt"&gt;--port&lt;/span&gt; &lt;span class="nv"&gt;$PORT&lt;/span&gt; &lt;span class="nt"&gt;--cidr&lt;/span&gt; &lt;span class="nv"&gt;$IP&lt;/span&gt;
        &lt;span class="k"&gt;fi
    fi
done&lt;/span&gt;

&lt;span class="c"&gt;# Remove IPs no longer in Cloudflare list&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;IP &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$CURRENT_IPS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CF_IPS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Removing &lt;/span&gt;&lt;span class="nv"&gt;$IP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;":"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
            &lt;/span&gt;aws ec2 revoke-security-group-ingress &lt;span class="nt"&gt;--group-id&lt;/span&gt; &lt;span class="nv"&gt;$SG_ID&lt;/span&gt; &lt;span class="nt"&gt;--ip-permissions&lt;/span&gt; &lt;span class="s2"&gt;"[{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;IpProtocol&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;tcp&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;FromPort&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;$PORT&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;ToPort&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;$PORT&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;Ipv6Ranges&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:[{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;CidrIpv6&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$IP&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}]}]"&lt;/span&gt;
        &lt;span class="k"&gt;else
            &lt;/span&gt;aws ec2 revoke-security-group-ingress &lt;span class="nt"&gt;--group-id&lt;/span&gt; &lt;span class="nv"&gt;$SG_ID&lt;/span&gt; &lt;span class="nt"&gt;--protocol&lt;/span&gt; tcp &lt;span class="nt"&gt;--port&lt;/span&gt; &lt;span class="nv"&gt;$PORT&lt;/span&gt; &lt;span class="nt"&gt;--cidr&lt;/span&gt; &lt;span class="nv"&gt;$IP&lt;/span&gt;
        &lt;span class="k"&gt;fi
    fi
done

&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Done"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set the script’s execute permissions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt;root@ip-10-0-3-146 ~]# &lt;span class="nb"&gt;chmod&lt;/span&gt; +x /opt/update-alb-sg/update-alb-sg-from-cloudflare.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it manually to verify:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0o6k3myhil5c2wgv5g2r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0o6k3myhil5c2wgv5g2r.png" width="699" height="508"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Check the Security Group:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F65y1bd2wnkfujts5qu0w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F65y1bd2wnkfujts5qu0w.png" width="799" height="302"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Replacing the Security Group on the ALB
&lt;/h3&gt;

&lt;p&gt;Edit the Security Groups list for the Load Balancer:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyatte0b256it5z0e3v3i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyatte0b256it5z0e3v3i.png" width="800" height="244"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Add the new one, remove the old one:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0uedrindogoixrpbto9h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0uedrindogoixrpbto9h.png" width="715" height="334"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Verify everything works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; https://rtfm.co.ua/
HTTP/2 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But from home, direct access to the ALB is now blocked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;--connect-timeout&lt;/span&gt; 5 &lt;span class="nt"&gt;-kI&lt;/span&gt; rtfm-alb-1984146384.eu-west-1.elb.amazonaws.com
curl: &lt;span class="o"&gt;(&lt;/span&gt;28&lt;span class="o"&gt;)&lt;/span&gt; Connection timed out after 5002 milliseconds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add it to cron.&lt;/p&gt;

&lt;h3&gt;
  
  
  Running from crontab
&lt;/h3&gt;

&lt;p&gt;In Amazon Linux AL2023, &lt;a href="https://docs.aws.amazon.com/linux/al2023/ug/cron.html" rel="noopener noreferrer"&gt;the standard cron was removed&lt;/a&gt; - install it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# yum install -y cronie&lt;/span&gt;
&lt;span class="c"&gt;# systemctl enable crond&lt;/span&gt;
&lt;span class="c"&gt;# systemctl start crond&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Schedule it to run once a day, at 3am for example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt; * * * /&lt;span class="n"&gt;opt&lt;/span&gt;/&lt;span class="n"&gt;update&lt;/span&gt;-&lt;span class="n"&gt;alb&lt;/span&gt;-&lt;span class="n"&gt;sg&lt;/span&gt;/&lt;span class="n"&gt;update&lt;/span&gt;-&lt;span class="n"&gt;alb&lt;/span&gt;-&lt;span class="n"&gt;sg&lt;/span&gt;-&lt;span class="n"&gt;from&lt;/span&gt;-&lt;span class="n"&gt;cloudflare&lt;/span&gt;.&lt;span class="n"&gt;sh&lt;/span&gt; &amp;gt;&amp;gt; /&lt;span class="n"&gt;var&lt;/span&gt;/&lt;span class="n"&gt;log&lt;/span&gt;/&lt;span class="n"&gt;update&lt;/span&gt;-&lt;span class="n"&gt;cf&lt;/span&gt;-&lt;span class="n"&gt;sg&lt;/span&gt;.&lt;span class="n"&gt;log&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;&amp;amp;&lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also subscribe to the &lt;a href="https://developers.cloudflare.com/changelog/" rel="noopener noreferrer"&gt;Cloudflare changelog&lt;/a&gt;/RSS to receive notifications when IP ranges change.&lt;/p&gt;

&lt;p&gt;But IP range changes are very rare - the last time was in 2023, see &lt;a href="https://www.cloudflare.com/ips/" rel="noopener noreferrer"&gt;IP Ranges&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxnc58u8krwjcwkfxjtwz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxnc58u8krwjcwkfxjtwz.png" width="563" height="330"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And that’s basically it.&lt;/p&gt;

&lt;p&gt;Now we can sleep soundly: our “frontend” is Cloudflare with its &lt;a href="https://developers.cloudflare.com/security/rules/" rel="noopener noreferrer"&gt;Security rules&lt;/a&gt;, WAF, and DoS/DDoS protection, and on the “backend” we’re protected at the network level, L3/L4 - and the Load Balancer and EC2 are living peacefully.&lt;/p&gt;

&lt;p&gt;See my post on &lt;a href="https://rtfm.co.ua/tcp-ip-modeli-osi-ta-tcp-ip-tcp-paketi-linux-sockets-i-porti/" rel="noopener noreferrer"&gt;TCP/IP: OSI and TCP/IP models, TCP packets, Linux sockets and ports&lt;/a&gt; and AWS’s own documentation - &lt;a href="https://docs.aws.amazon.com/eks/latest/best-practices/network-security.html" rel="noopener noreferrer"&gt;Network security&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;P.S. AWS documentation is one of the things I genuinely love about it. I once had to work extensively with Azure - and this is a real problem there.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;/em&gt; &lt;a href="https://rtfm.co.ua/en/aws-alb-and-cloudflare-configuring-mtls-and-aws-security-rules/" rel="noopener noreferrer"&gt;&lt;em&gt;RTFM: Linux, DevOps, and system administration&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>aws</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>AWS: Monitoring AWS OpenSearch Service Cluster with CloudWatch</title>
      <dc:creator>Arseny Zinchenko</dc:creator>
      <pubDate>Wed, 31 Dec 2025 10:00:00 +0000</pubDate>
      <link>https://dev.to/setevoy/aws-monitoring-aws-opensearch-service-cluster-with-cloudwatch-385o</link>
      <guid>https://dev.to/setevoy/aws-monitoring-aws-opensearch-service-cluster-with-cloudwatch-385o</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj65j5emcorj18ix3o5qr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj65j5emcorj18ix3o5qr.png" width="640" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let’s continue our journey with AWS OpenSearch Service.&lt;/p&gt;

&lt;p&gt;What we have is a small AWS OpenSearch Service cluster with three data nodes, used as a vector store for AWS Bedrock Knowledge Bases.&lt;/p&gt;

&lt;p&gt;Previous parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/aws-introduction-to-the-opensearch-service-as-a-vector-store/" rel="noopener noreferrer"&gt;AWS: Introduction to OpenSearch Service as a vector store&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/aws-creating-an-opensearch-service-cluster-and-configuring-authentication-and-authorization/" rel="noopener noreferrer"&gt;AWS: Creating an OpenSearch Service cluster and configuring authentication and authorization&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/terraform-stvorennya-aws-opensearch-service-cluster-ta-yuzeriv/" rel="noopener noreferrer"&gt;Terraform: creating an AWS OpenSearch Service cluster and users&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We already had our first production incident :-)&lt;/p&gt;

&lt;p&gt;We launched a search without filters, and our &lt;code&gt;t3.small.search&lt;/code&gt; died due to CPU overload.&lt;/p&gt;

&lt;p&gt;So let’s take a look at what we have in terms of monitoring all this happiness.&lt;/p&gt;

&lt;h3&gt;
  
  
  Contents
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;CloudWatch metrics&lt;/li&gt;
&lt;li&gt;Memory monitoring&lt;/li&gt;
&lt;li&gt;kNN Memory usage&lt;/li&gt;
&lt;li&gt;JVM Memory usage&lt;/li&gt;
&lt;li&gt;Collecting metrics to VictoriaMetrics&lt;/li&gt;
&lt;li&gt;Creating a Grafana dashboard&lt;/li&gt;
&lt;li&gt;VictoriaMetrics/Prometheus sum(), avg() та max()&lt;/li&gt;
&lt;li&gt;Cluster status&lt;/li&gt;
&lt;li&gt;Nodes status&lt;/li&gt;
&lt;li&gt;CPUUtilization: Stats&lt;/li&gt;
&lt;li&gt;CPUUtilization: Graph&lt;/li&gt;
&lt;li&gt;JVMMemoryPressure: Graph&lt;/li&gt;
&lt;li&gt;JVMGCYoungCollectionCount and JVMGCOldCollectionCount&lt;/li&gt;
&lt;li&gt;KNNHitCount vs KNNMissCount&lt;/li&gt;
&lt;li&gt;Final result&lt;/li&gt;
&lt;li&gt;t3.small.search vs t3.medium.search on graphs&lt;/li&gt;
&lt;li&gt;Creating Alerts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now let’s do something basic, just with CloudWatch metrics, but there are several solutions for monitoring OpenSearch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CloudWatch metrics from OpenSearchService itself — data on CPU, memory, and JVM, which we can collect in VictoriaMetrics and generate alerts or use in the Grafana dashboard, see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/managedomains-cloudwatchmetrics.html" rel="noopener noreferrer"&gt;Monitoring OpenSearch cluster metrics with Amazon CloudWatch&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;CloudWatch Events generated by OpenSearch Service — see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/monitoring-events.html#monitoring-events-throughput-throttle" rel="noopener noreferrer"&gt;Monitoring OpenSearch Service events with Amazon EventBridge&lt;/a&gt; — can be sent via SNS to Opsgenie, and from there to Slack.&lt;/li&gt;
&lt;li&gt;Logs in CloudWatch Logs — we can collect them in VictoriaLogs and generate some metrics and alerts, but I didn’t see anything interesting in the logs during our production incident, see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/createdomain-configure-slow-logs.html" rel="noopener noreferrer"&gt;Monitoring OpenSearch logs with Amazon CloudWatch Logs&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.opensearch.org/latest/observing-your-data/alerting/monitors/" rel="noopener noreferrer"&gt;Monitors&lt;/a&gt; of OpenSearch itself — capable of anomaly detection and custom alerting, there is even a Terraform resource &lt;a href="https://registry.terraform.io/providers/opensearch-project/opensearch/latest/docs/resources/monitor" rel="noopener noreferrer"&gt;&lt;code&gt;opensearch_monitor&lt;/code&gt;&lt;/a&gt;, see also &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/alerting.html" rel="noopener noreferrer"&gt;Configuring alerts in Amazon OpenSearch Service&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;There is also the Prometheus Exporter Plugin, which opens an endpoint for collecting metrics from Prometheus/VictoriaMetrics (but it cannot be added to AWS OpenSearch Managed, although support promises that there is a feature request — maybe it will be added someday).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  CloudWatch metrics
&lt;/h3&gt;

&lt;p&gt;There are quite a few metrics, but the ones that may be of interest to us are those that take into account the fact that we do not have dedicated master and coordinator nodes, and we do not use ultra-warm and cold instances.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cluster metrics&lt;/strong&gt; :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ClusterStatus&lt;/code&gt;: &lt;code&gt;green&lt;/code&gt;/&lt;code&gt;yellow&lt;/code&gt;/&lt;code&gt;red&lt;/code&gt; - the main indicator of cluster status, control of data shard activity&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Shards&lt;/code&gt;: &lt;code&gt;active&lt;/code&gt;/&lt;code&gt;unassigned&lt;/code&gt;/&lt;code&gt;delayedUnassigned&lt;/code&gt;/&lt;code&gt;activePrimary&lt;/code&gt;/&lt;code&gt;initializing&lt;/code&gt;/&lt;code&gt;relocating&lt;/code&gt; - more detailed information on the status of shards, but here is just the total number, without details on specific indexes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Nodes&lt;/code&gt;: the number of nodes in the cluster - knowing how many live nodes there should be - we can alert when a node goes down&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SearchableDocuments&lt;/code&gt;: not that it's particularly interesting to us, but it might be useful later on to see what's going on in the indexes in general.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CPUUtilization&lt;/code&gt;: the percentage of CPU usage across all nodes, and this is a must-have&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;FreeStorageSpace&lt;/code&gt;: also useful to monitor&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ClusterIndexWritesBlocked&lt;/code&gt;: Is everything OK with index writes?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;JVMMemoryPressure&lt;/code&gt; and &lt;code&gt;OldGenJVMMemoryPressure&lt;/code&gt;: percentage of JVM heap memory usage - we'll dig into JVM monitoring separately later, because it's a whole other headache.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AutomatedSnapshotFailure&lt;/code&gt;: probably good to know if the backup fails&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CPUCreditBalance&lt;/code&gt;: useful for us because we are on t3 instances (but we don't have it in CloudWatch)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;2xx&lt;/code&gt;, &lt;code&gt;3xx&lt;/code&gt;, &lt;code&gt;4xx&lt;/code&gt;, &lt;code&gt;5xx&lt;/code&gt;: data on HTTP requests and errors; I only collect &lt;code&gt;5xx&lt;/code&gt; for alerts here&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ThroughputThrottle&lt;/code&gt; and &lt;code&gt;IopsThrottle&lt;/code&gt;: we encountered disk access issues in RDS, so it is worth monitoring here as well, see &lt;a href="https://rtfm.co.ua/en/postgresql-aws-rds-performance-and-monitoring-2/" rel="noopener noreferrer"&gt;PostgreSQL: AWS RDS Performance and monitoring&lt;/a&gt;; here you may need to look at the metrics from &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/managedomains-cloudwatchmetrics.html#managedomains-cloudwatchmetrics-master-ebs-metrics" rel="noopener noreferrer"&gt;EBS volume metrics&lt;/a&gt;, but for start, you can simply add alerts to Throttle in general&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;HighSwapUsage&lt;/code&gt;: similar to the previous metrics - we once had a problem with RDS, so it's better to monitor this as well.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;EBS volume metrics &lt;/strong&gt; — these are basically standard EBS metrics, as for EC2 or RDS:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ReadLatency&lt;/code&gt; and &lt;code&gt;WriteLatency&lt;/code&gt;: read/write delays

&lt;ul&gt;
&lt;li&gt;sometimes there are spikes, so you can add&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;ReadThroughput&lt;/code&gt; and &lt;code&gt;WriteThroughput&lt;/code&gt;: total disk load, let's say this way&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;DiskQueueDepth&lt;/code&gt;: I/O operations queue

&lt;ul&gt;
&lt;li&gt;is empty in CloudWatch (for now?), so we’ll skip it&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;ReadIOPS&lt;/code&gt; and &lt;code&gt;WriteIOPS&lt;/code&gt;: number of read/write operations per second&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Instance metrics&lt;/strong&gt;  — here are the metrics for each OpenSearch instance (not the server, EC2, but OpenSearch itself) on each node:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;FetchLatency&lt;/code&gt; and &lt;code&gt;FetchRate&lt;/code&gt;: how quickly we get data from shards (but I couldn't find it in CloudWatch either)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ThreadCount&lt;/code&gt;: the number of threads in the operating system that were created by the JVM (Garbage Collector threads, search threads, write/index threads, etc.)&lt;/li&gt;
&lt;li&gt;The value is stable in CloudWatch, but for now, we can add it to Grafana for the overall picture and see if there is anything interesting there&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ShardReactivateCount&lt;/code&gt;: how often shards are transferred from cold/inactive states to active ones, which requires operating system resources, CPU, and memory; Well... maybe we should check if it has any significance for us at all.

&lt;ul&gt;
&lt;li&gt;But there is nothing in CloudWatch either — “&lt;em&gt;did not match any metrics&lt;/em&gt;”&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;ConcurrentSearchRate&lt;/code&gt; and &lt;code&gt;ConcurrentSearchLatency&lt;/code&gt;: the number and speed of simultaneous search requests - this can be interesting if there are many parallel requests hanging for a long time&lt;/li&gt;

&lt;li&gt;but for us (yet?), these values are constantly at zero, so we skip them&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;SearchRate&lt;/code&gt;: number of search queries per minute, useful for the overall picture&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;SearchLatency&lt;/code&gt;: search query execution speed, probably very useful, you can even set up an alert&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;IndexingRate&lt;/code&gt; and &lt;code&gt;IndexingLatency&lt;/code&gt;: similar, but for indexing new documents&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;SysMemoryUtilization&lt;/code&gt;: percentage of memory usage on the data node, but this does not give a complete picture; you need to look at the JVM memory.&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;JVMGCYoungCollectionCount&lt;/code&gt; and &lt;code&gt;JVMGCOldCollectionCount&lt;/code&gt;: the number of Garbage Collector runs, useful in conjunction with JVM memory data, which we will discuss in more detail later.&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;SearchTaskCancelled&lt;/code&gt; and &lt;code&gt;SearchShardTaskCancelled&lt;/code&gt;: bad news :-) if tasks are canceled, something is clearly wrong (either the user interrupted the request, or there was an HTTP connection reset, or timeouts, or cluster load)

&lt;ul&gt;
&lt;li&gt;but we always have zeroes, even when the cluster went down, so I don’t see the point in collecting these metrics yet&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;ThreadpoolIndexQueue&lt;/code&gt; and &lt;code&gt;ThreadpoolSearchQueue&lt;/code&gt;: the number of tasks for indexing and searching in the queue; when there are too many of them, we get &lt;code&gt;ThreadpoolIndexRejected&lt;/code&gt; and &lt;code&gt;ThreadpoolSearchRejected&lt;/code&gt;
&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;ThreadpoolIndexQueue&lt;/code&gt; is not available in CloudWatch at all, and &lt;code&gt;ThreadpoolSearchQueue&lt;/code&gt; is there, but it's also constantly at zero, so we're skipping it for now&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;ThreadpoolIndexRejected&lt;/code&gt; and &lt;code&gt;ThreadpoolSearchRejected&lt;/code&gt;: actually, above&lt;/li&gt;

&lt;li&gt;in CloudWatch, the picture is similar — ThreadpoolIndexRejected is not present at all, ThreadpoolSearchRejected is zero&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;ThreadpoolIndexThreads&lt;/code&gt; and &lt;code&gt;ThreadpoolSearchThreads&lt;/code&gt;: the maximum number of operating system threads for indexing and searching; if all are busy, requests will go to &lt;code&gt;ThreadpoolIndexQueue&lt;/code&gt;/&lt;code&gt;ThreadpoolSearchQueue&lt;/code&gt;

&lt;ul&gt;
&lt;li&gt;OpenSearch has several types of pools for threads — search, index, write, etc., and each pool has a threads indicator (how many are allocated), see &lt;a href="https://opster.com/guides/opensearch/opensearch-basics/threadpool/" rel="noopener noreferrer"&gt;OpenSearch Threadpool&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;The Node Stats API (&lt;code&gt;GET _nodes/stats/thread_pool&lt;/code&gt;) has an active threads metric, but I don't see it in CloudWatch.&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;ThreadpoolIndexThreads&lt;/code&gt; is not available in CloudWatch at all, and &lt;code&gt;ThreadpoolSearchThreads&lt;/code&gt; is static, so I think we can skip monitoring them for now.&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;PrimaryWriteRejected&lt;/code&gt;: rejected write operations in primary shards due to issues in the thread pool write or index, or load on the data node

&lt;ul&gt;
&lt;li&gt;CloudWatch is empty for now, but we will add collection and alerts&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;ReplicaWriteRejected&lt;/code&gt;: rejected write operations in replica shards - added to the primary document, but cannot be written to the replica

&lt;ul&gt;
&lt;li&gt;CloudWatch is empty for now, but we will add collection and alerts&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;k-NN metrics&lt;/strong&gt;  — useful for us because we have a vector store with k-NN:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;KNNCacheCapacityReached&lt;/code&gt;: when the cache is full (see below)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;KNNEvictionCount&lt;/code&gt;: how often data is removed from the cache - a sign that there is not enough memory&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;KNNGraphMemoryUsage&lt;/code&gt;: off-heap memory usage for the vector graph itself&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;KNNGraphQueryErrors&lt;/code&gt;: number of errors when searching in vectors

&lt;ul&gt;
&lt;li&gt;in CloudWatch are empty for now, but we will add collection and alert&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;KNNGraphQueryRequests&lt;/code&gt;: total number of queries to k-NN graphs&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;KNNHitCount&lt;/code&gt; and &lt;code&gt;KNNMissCount&lt;/code&gt;: how many results were returned from the cache, and how many had to be read from the disk&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;KNNTotalLoadTime&lt;/code&gt;: speed of loading from disk to cache (large graphs or loaded EBS - time will increase)&lt;/li&gt;

&lt;/ul&gt;

&lt;h3&gt;
  
  
  Memory monitoring
&lt;/h3&gt;

&lt;p&gt;Let’s think about how we can monitor the main indicators, starting with memory, because, well, this is Java.&lt;/p&gt;

&lt;p&gt;What do we have about memory metrics?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SysMemoryUtilization&lt;/code&gt;: percentage of memory usage on the server (data node) in general&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;JVMMemoryPressure&lt;/code&gt;: total percentage of JVM Heap usage; JVM Heap is allocated by default to 50% of the server's memory, but no more than 32 gigabytes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;OldGenJVMMemoryPressure&lt;/code&gt;: see below&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;KNNGraphMemoryUsage&lt;/code&gt;: this was discussed in the first post - &lt;a href="https://rtfm.co.ua/en/aws-introduction-to-the-opensearch-service-as-a-vector-store/" rel="noopener noreferrer"&gt;AWS: introduction to OpenSearch Service as a vector store&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;CloudWatch also has a metric called &lt;code&gt;KNNGraphMemoryUsagePercentage&lt;/code&gt;, but it is not included in the documentation&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  kNN Memory usage
&lt;/h3&gt;

&lt;p&gt;First, a brief overview of k-NN memory.&lt;/p&gt;

&lt;p&gt;So, on EC2, we allocate memory for the JVM Heap (50% of what is available on the server) and separately for the off-heap for the OpenSearch vector store, where it keeps graphs and cache. For vector store, see &lt;a href="https://docs.opensearch.org/1.0/search-plugins/knn/approximate-knn/" rel="noopener noreferrer"&gt;Approximate k-NN search&lt;/a&gt;, plus the operating system itself and its file cache.&lt;/p&gt;

&lt;p&gt;We don’t have a metric like “&lt;em&gt;KNNGraphMemoryAvailable&lt;/em&gt;,” but with &lt;code&gt;KNNGraphMemoryUsagePercentage&lt;/code&gt; and &lt;code&gt;KNNGraphMemoryUsage&lt;/code&gt;, we can calculate it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;KNNGraphMemoryUsage&lt;/code&gt;: we currently have 662 megabytes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;KNNGraphMemoryUsagePercentage&lt;/code&gt;: 60%&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This means that 1 gigabyte is allocated outside the JVM Heap memory for k-NN graphs (this is on &lt;code&gt;t3.medium.search&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;From the documentation &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/knn.html?utm_source=chatgpt.com" rel="noopener noreferrer"&gt;k-Nearest Neighbor (k-NN) search in Amazon OpenSearch Service&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;OpenSearch Service uses half of an instance’s RAM for the Java heap (up to a heap size of 32 GiB). By default, k-NN uses up to 50% of the remaining half&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Knowing that we currently have &lt;a href="https://instances.vantage.sh/aws/opensearch/t3.medium.search?currency=USD" rel="noopener noreferrer"&gt;&lt;code&gt;t3.medium.search&lt;/code&gt;&lt;/a&gt;, which provides 4 gigabytes of memory - 2 GB goes to the JVM Heap, and 1 gigabyte goes to the k-NN graph.&lt;/p&gt;

&lt;p&gt;The main part of KNNGraphMemory is used by the k-NN cache, i.e., the part of the system's RAM where OpenSearch keeps HNSW graphs from vector indexes so that they do not have to be read from disk each time (see &lt;a href="https://docs.opensearch.org/latest/vector-search/api/knn/#k-nn-clear-cache" rel="noopener noreferrer"&gt;k-NN clear cache&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Therefore, it is useful to have graphs for EBS IOPS and k-NN cache usage.&lt;/p&gt;

&lt;h3&gt;
  
  
  JVM Memory usage
&lt;/h3&gt;

&lt;p&gt;Okay, let’s review what’s going on in Java in general. See &lt;a href="https://sematext.com/glossary/jvm-heap/" rel="noopener noreferrer"&gt;What Is Java Heap Memory?&lt;/a&gt;, &lt;a href="https://opster.com/guides/opensearch/opensearch-basics/opensearch-heap-size-usage-and-jvm-garbage-collection/" rel="noopener noreferrer"&gt;OpenSearch Heap Size Usage and JVM Garbage Collection&lt;/a&gt;, and &lt;a href="https://aws.amazon.com/blogs/big-data/understanding-the-jvmmemorypressure-metric-changes-in-amazon-opensearch-service/?utm_source=chatgpt.com" rel="noopener noreferrer"&gt;Understanding the JVMMemoryPressure metric changes in Amazon OpenSearch Service&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;To put it simply:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stack Memory:&lt;/strong&gt; in addition to the JVM Heap, we have a Stack, which is allocated to each thread, where it keeps its variables, references, and startup parameters

&lt;ul&gt;
&lt;li&gt;set via &lt;code&gt;-Xss&lt;/code&gt;, default value from 256 kilobytes to 1 megabyte, see &lt;a href="https://docs.oracle.com/cd/E13150_01/jrockit_jvm/jrockit/geninfo/diagnos/thread_basics.html" rel="noopener noreferrer"&gt;Understanding Threads and Locks&lt;/a&gt; (couldn't find how to view in OpenSearch Service)&lt;/li&gt;
&lt;li&gt;if we have many threads, there will be a lot of memory for their stacks&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;cleared when the thread dies&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;Heap Space&lt;/strong&gt; :&lt;/li&gt;

&lt;li&gt;used to allocate memory that is available to all threads&lt;/li&gt;

&lt;li&gt;managed by Garbage Collectors (GC)&lt;/li&gt;

&lt;li&gt;in the context of OpenSearch, we will have search and indexation caches here&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;In Heap memory, we have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Young Generation&lt;/strong&gt; : fresh data, all new objects&lt;/li&gt;
&lt;li&gt;data from here is either deleted completely or moved to Old Generation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Old Generation&lt;/strong&gt; : the OpenSearch process code itself, caches, Lucene index structures, large arrays&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If &lt;code&gt;OldGenJVMMemoryPressure&lt;/code&gt; is full, it means that the Garbage Collector cannot clean it up because there are references to the data, and then we have a problem - because there is no space in the Heap for new data, and the JVM may crash with an OutOfMemoryError.&lt;/p&gt;

&lt;p&gt;In general, “heap pressure” is when there is little free memory in Young Gen and Old Gen, and there is nowhere to place new data to respond to clients.&lt;/p&gt;

&lt;p&gt;This leads to frequent Garbage Collector runs, which take up time and system resources — instead of processing requests from clients.&lt;/p&gt;

&lt;p&gt;As a result, latency increases, indexing of new documents slows down, or we get &lt;code&gt;ClusterIndexWritesBlocked&lt;/code&gt; - to avoid Java &lt;strong&gt;OutOfMemoryError&lt;/strong&gt; , because when indexing, OpenSearch first writes data to the Heap and then "dumps" it to disk.&lt;/p&gt;

&lt;p&gt;See &lt;a href="https://sematext.com/blog/jvm-metrics/" rel="noopener noreferrer"&gt;Key JVM Metrics to Monitor for Peak Java Application Performance&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So, to get a picture of memory usage, we monitor:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SysMemoryUtilization&lt;/code&gt; - for an overall picture of the EC2 status

&lt;ul&gt;
&lt;li&gt;in our case, it will be consistently around 90%, but that’s OK&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;JVMMemoryPressure&lt;/code&gt; - for an overall picture of the JVM

&lt;ul&gt;
&lt;li&gt;should be cleaned regularly with Garbage Collector (GC)&lt;/li&gt;
&lt;li&gt;if it is constantly above 80–90%, there are problems with running GC&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;OldGenJVMMemoryPressure&lt;/code&gt; - for Old Generation Heap data

&lt;ul&gt;
&lt;li&gt;should be at 30–40%; if it is higher and is not being cleared, then there are problems either with the code or with GC&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;KNNGraphMemoryUsage&lt;/code&gt; - in our case, this is necessary for the overall picture&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;It is worth adding alerts for &lt;code&gt;HighSwapUsage&lt;/code&gt; - we already had active swapping when we launched on &lt;code&gt;t3.small.search&lt;/code&gt;, and this is an indication that there is not enough memory.&lt;/p&gt;

&lt;h3&gt;
  
  
  Collecting metrics to VictoriaMetrics
&lt;/h3&gt;

&lt;p&gt;So, how do you choose metrics?&lt;/p&gt;

&lt;p&gt;First, we look for them in CloudWatch Metrics and see if the metric exists at all and if it returns any interesting data.&lt;/p&gt;

&lt;p&gt;For example, &lt;code&gt;SysMemoryUtilization&lt;/code&gt; provides information.&lt;/p&gt;

&lt;p&gt;Here we had a spike on &lt;code&gt;t3.small.search&lt;/code&gt;, after which the cluster crashed:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnmet4thyahram7qlg250.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnmet4thyahram7qlg250.png" width="800" height="422"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But the &lt;code&gt;HighSwapUsage&lt;/code&gt; metric also needs to be moved to &lt;code&gt;t3.medium.search&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjrux00i2ubtizcfc0o0k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjrux00i2ubtizcfc0o0k.png" width="608" height="661"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ClusterStatus&lt;/code&gt; is present:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7c1od192dkmm5ojq1igv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7c1od192dkmm5ojq1igv.png" width="608" height="661"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Shards&lt;/code&gt; exist, but they are indexed by all criteria, and there is no way to filter by individual criteria:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fifo88tza9ji3kprmvu7v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fifo88tza9ji3kprmvu7v.png" width="800" height="620"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It is also important to note that collecting metrics from CloudWatch also costs money for API requests, so it is not advisable to collect everything indiscriminately.&lt;/p&gt;

&lt;p&gt;In general, we use YACE (Yet Another CloudWatch Exporter) to collect metrics from CloudWatch, but it does not support OpenSearch Managed cluster, see &lt;a href="https://github.com/prometheus-community/yet-another-cloudwatch-exporter?tab=readme-ov-file#features" rel="noopener noreferrer"&gt;Features&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Therefore, we will use a standard exporter — CloudWatch Exporter.&lt;/p&gt;

&lt;p&gt;We deploy it from the Helm monitoring chart (see &lt;a href="https://rtfm.co.ua/en/victoriametrics-deploying-a-kubernetes-monitoring-stack/" rel="noopener noreferrer"&gt;VictoriaMetrics: creating a Kubernetes monitoring stack with your own Helm chart&lt;/a&gt;), add a new config to it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...

prometheus-cloudwatch-exporter:
  enabled: true
  serviceAccount:
    name: "cloudwatch-sa"
    annotations:
      eks.amazonaws.com/sts-regional-endpoints: "true"
  serviceMonitor:
    enabled: true
  config: |-
    region: us-east-1
    metrics:

    - aws_namespace: AWS/ES
      aws_metric_name: KNNGraphMemoryUsage
      aws_dimensions: [ClientId, DomainName, NodeId]
      aws_statistics: [Average]

    - aws_namespace: AWS/ES
      aws_metric_name: SysMemoryUtilization
      aws_dimensions: [ClientId, DomainName, NodeId]
      aws_statistics: [Average]

    - aws_namespace: AWS/ES
      aws_metric_name: JVMMemoryPressure
      aws_dimensions: [ClientId, DomainName, NodeId]
      aws_statistics: [Average]

    - aws_namespace: AWS/ES
      aws_metric_name: OldGenJVMMemoryPressure
      aws_dimensions: [ClientId, DomainName, NodeId]
      aws_statistics: [Average]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Please note that different metrics may have different &lt;code&gt;Dimensions&lt;/code&gt; - check them in CloudWatch:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0ntrfwvf28gni62jeqfb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0ntrfwvf28gni62jeqfb.png" width="573" height="128"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Deploy, check:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd1rqd5g5uvguewwi3mn3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd1rqd5g5uvguewwi3mn3.png" width="800" height="468"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And even the numbers turned out to be as we &lt;a href="https://rtfm.co.ua/en/aws-introduction-to-the-opensearch-service-as-a-vector-store/#Number_of_vectors" rel="noopener noreferrer"&gt;calculated in the first post&lt;/a&gt; — we have ~130,000 documents in the production index, according to the formula &lt;code&gt;num_vectors * 1.1 * (4*1024 + 8*16)&lt;/code&gt;, which equals 604032000 bytes, or 604.032 megabytes.&lt;/p&gt;

&lt;p&gt;And on the graph we have 662,261 kilobytes — that’s 662 megabytes, but across all indexes combined.&lt;/p&gt;

&lt;p&gt;Now we have metrics in VictoriaMetrics : &lt;code&gt;aws_es_knngraph_memory_usage_average&lt;/code&gt;, &lt;code&gt;aws_es_sys_memory_utilization_average&lt;/code&gt;, &lt;code&gt;aws_es_jvmmemory_pressure_average&lt;/code&gt;, &lt;code&gt;aws_es_old_gen_jvmmemory_pressure_average&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Add the rest in the same way.&lt;/p&gt;

&lt;p&gt;To find out what metrics are called in VictoriaMetrics/Prometheus, open the port to CloudWatch Exporter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk port-forward svc/atlas-victoriametrics-prometheus-cloudwatch-exporter 9106
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And search for metrics with &lt;code&gt;curl&lt;/code&gt; and &lt;code&gt;grep&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s localhost:9106/metrics | grep aws_es
# HELP aws_es_cluster_status_green_maximum CloudWatch metric AWS/ES ClusterStatus.green Dimensions: [ClientId, DomainName] Statistic: Maximum Unit: Count
# TYPE aws_es_cluster_status_green_maximum gauge
aws_es_cluster_status_green_maximum{job="aws_es",instance="",domain_name="atlas-kb-prod-cluster",client_id="492***148",} 1.0 1758014700000
# HELP aws_es_cluster_status_yellow_maximum CloudWatch metric AWS/ES ClusterStatus.yellow Dimensions: [ClientId, DomainName] Statistic: Maximum Unit: Count
# TYPE aws_es_cluster_status_yellow_maximum gauge
aws_es_cluster_status_yellow_maximum{job="aws_es",instance="",domain_name="atlas-kb-prod-cluster",client_id="492***148",} 0.0 1758014700000
# HELP aws_es_cluster_status_red_maximum CloudWatch metric AWS/ES ClusterStatus.red Dimensions: [ClientId, DomainName] Statistic: Maximum Unit: Count
# TYPE aws_es_cluster_status_red_maximum gauge
aws_es_cluster_status_red_maximum{job="aws_es",instance="",domain_name="atlas-kb-prod-cluster",client_id="492***148",} 0.0 1758014700000
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Creating a Grafana dashboard
&lt;/h3&gt;

&lt;p&gt;OK, we have metrics from CloudWatch — that’s enough for now.&lt;/p&gt;

&lt;p&gt;Let’s think about what we want to see in Grafana.&lt;/p&gt;

&lt;p&gt;The general idea is to create a kind of dashboard overview, where all the key data for the cluster will be displayed on a single board.&lt;/p&gt;

&lt;p&gt;What metrics are currently available, and how can we use them in Grafana? I wrote them down here so as not to get confused, because there are quite a few of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;aws_es_cluster_status_green_maximum&lt;/code&gt;, &lt;code&gt;aws_es_cluster_status_yellow_maximum&lt;/code&gt;, &lt;code&gt;aws_es_cluster_status_red_maximum&lt;/code&gt;: you can create a single Stats panel&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_nodes_maximum&lt;/code&gt;: also some kind of stats panel - we know how many there should be, and we'll mark it red when there are fewer Data Nodes than there should be.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_searchable_documents_maximum&lt;/code&gt;: just for fun, we will show the number of documents in all indexes together in a graph&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_cpuutilization_average&lt;/code&gt;: one graph per node, and some Stats with general information and different colors&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_free_storage_space_maximum&lt;/code&gt;: just Stats&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_cluster_index_writes_blocked_maximum&lt;/code&gt;: did not add to Grafana, only alert&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_jvmmemory_pressure_average&lt;/code&gt;: graph and stats&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_old_gen_jvmmemory_pressure_average&lt;/code&gt;: somewhere nearby, also graph + Stats&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_automated_snapshot_failure_maximum&lt;/code&gt;: this is just for alerting&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_5xx_maximum&lt;/code&gt;: both graph and Stats&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_iops_throttle_maximum&lt;/code&gt;: graph to see in comparison with other data such as CPU/Mem usage&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_throughput_throttle_maximum&lt;/code&gt;: graph&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_high_swap_usage_maximum&lt;/code&gt;: both graph and Stats - graph, to see in comparison with CPU/disks&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_read_latency_average&lt;/code&gt;: graph&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_write_latency_average&lt;/code&gt;: graph&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_read_throughput_average&lt;/code&gt;: I didn't add it because there are too many graphs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_write_throughput_average&lt;/code&gt;: I didn't add it because there are too many graphs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_read_iops_average&lt;/code&gt;: a graph that is useful for understanding how the k-NN cache works - if there is not enough of it (and we tested on &lt;code&gt;t3.small.searc&lt;/code&gt;h with 2 gigabytes of total memory), then there will be a lot of reading from the disk.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_write_iops_average&lt;/code&gt;: similarly&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_thread_count_average&lt;/code&gt;: I didn't add it because it's pretty static and I didn't see any particularly useful information in it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_search_rate_average&lt;/code&gt;: also just a graph&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_search_latency&lt;/code&gt;: similarly, somewhere nearby&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_sys_memory_utilization_average&lt;/code&gt;: Well, it will constantly be around 90% until I remove it from Grafana, but I added it to alerts.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_jvmgcyoung_collection_count_average&lt;/code&gt;: graph showing how often it is called&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_jvmgcold_collection_count_average&lt;/code&gt;: graph showing how often it is called&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_primary_write_rejected_average&lt;/code&gt;: graph, but I haven't added it yet because there are too many graphs - only alerts&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_replica_write_rejected_average&lt;/code&gt;: graph, but I haven't added it yet because there are too many graphs - only alerts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;k-NN:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;aws_es_knncache_capacity_reached_maximum&lt;/code&gt;: only for warning alerts&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_knneviction_count_average&lt;/code&gt;: did not add, although it may be interesting&lt;/li&gt;
&lt;li&gt;`aws_es_knngraph_memory_usage_average: did not add&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_knngraph_memory_usage_percentage_maximum&lt;/code&gt;: graph instead of aws_es_knngraph_memory_usage_average&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_knngraph_query_errors_maximum&lt;/code&gt;: alert only&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_knngraph_query_requests_sum&lt;/code&gt;: graph&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_knnhit_count_maximum&lt;/code&gt;: graph&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_knnmiss_count_maximum&lt;/code&gt;: graph&lt;/li&gt;
&lt;li&gt;`aws_es_knntotal_load_time_sum: it would be nice to have a graph, but there is no space on the board&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  VictoriaMetrics/Prometheus &lt;code&gt;sum()&lt;/code&gt;, &lt;code&gt;avg()&lt;/code&gt; and max()`
&lt;/h3&gt;

&lt;p&gt;First, let’s recall what functions we have for data aggregation.&lt;/p&gt;

&lt;p&gt;With CloudWatch for OpenSearch, we will receive two main types: &lt;em&gt;counter&lt;/em&gt; and &lt;em&gt;gauge&lt;/em&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
$ curl -s localhost:9106/metrics | grep cpuutil&lt;/p&gt;
&lt;h1&gt;
  
  
  HELP aws_es_cpuutilization_average CloudWatch metric AWS/ES CPUUtilization Dimensions: [ClientId, DomainName, NodeId] Statistic: Average Unit: Percent
&lt;/h1&gt;
&lt;h1&gt;
  
  
  TYPE aws_es_cpuutilization_average gauge
&lt;/h1&gt;

&lt;p&gt;aws_es_cpuutilization_average{job="aws_es",instance="",domain_name="atlas-kb-prod-cluster",node_id="BzX51PLwSRCJ7GrbgB4VyA",client_id="492***148",} 10.0 1758099600000&lt;br&gt;
...&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The difference between them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;counter&lt;/strong&gt; : the value can only increase the value&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;gauge&lt;/strong&gt; : the value can increase and decrease&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here we have “&lt;code&gt;TYPE aws_es_cpuutilization_average gauge"&lt;/code&gt;, because CPU usage can both increase and decrease.&lt;/p&gt;

&lt;p&gt;See the excellent documentation &lt;a href="https://victoriametrics.com/blog/prometheus-monitoring-metrics-counters-gauges-histogram-summaries/" rel="noopener noreferrer"&gt;VictoriaMetrics — Prometheus Metrics Explained: Counters, Gauges, Histograms &amp;amp; Summaries&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;How can we use it in graphs?&lt;/p&gt;

&lt;p&gt;If we just look at the values, we have a set of labels here, each forming its own time series:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;aws_es_cpuutilization_average{node_id="BzX51PLwSRCJ7GrbgB4VyA"}&lt;/code&gt; == 9&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_cpuutilization_average{node_id="IIEcajw5SfmWCXe_AZMIpA"}&lt;/code&gt; == 28&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_cpuutilization_average{node_id="lrsnwK1CQgumpiXfhGq06g"}&lt;/code&gt; == 8&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F10fhzk5u2n24wkkcq4l5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F10fhzk5u2n24wkkcq4l5.png" width="800" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;sum()&lt;/code&gt; without a label, we simply get the sum of all values:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2wkw22l0k7r83q6xolwk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2wkw22l0k7r83q6xolwk.png" width="800" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If we do &lt;code&gt;sum by (node_id)&lt;/code&gt;, we will get the value for a specific time series, which will coincide with the sample without &lt;code&gt;sum by ()&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7mq8jlrmsnsmpgc2kkfo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7mq8jlrmsnsmpgc2kkfo.png" width="800" height="531"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;(&lt;em&gt;the meaning changes as I write and make inquiries&lt;/em&gt;)&lt;/p&gt;

&lt;p&gt;With `max() without filters, we simply obtain the maximum value selected from all the time series received:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvxsblkjlag0fw31n0xjw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvxsblkjlag0fw31n0xjw.png" width="800" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And with &lt;code&gt;avg()&lt;/code&gt; - the average value of all values, i.e., the sum of all values divided by the number of time series:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff4t5yuvmkol9vhjzce92.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff4t5yuvmkol9vhjzce92.png" width="800" height="539"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let’s calculate it ourselves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(41+46+12)/3
33
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Actually, the reason I decided to write about this separately is because even with &lt;code&gt;sum()&lt;/code&gt; and &lt;code&gt;by (node_id)&lt;/code&gt;, you can sometimes get the following results:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgutjlwd4xvf6dgal3odz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgutjlwd4xvf6dgal3odz.png" width="614" height="774"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Although without &lt;code&gt;sum()&lt;/code&gt; there are none:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5tylj9dw5qpdsoneb059.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5tylj9dw5qpdsoneb059.png" width="614" height="774"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And they happened because Pod was being recreated from CloudWatch Exporter at that moment:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsjrr6ov208hcdsamp65f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsjrr6ov208hcdsamp65f.png" width="614" height="731"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And at that moment, we were receiving data from the old pod and the new one.&lt;/p&gt;

&lt;p&gt;Therefore, the options here are to use either &lt;code&gt;max()&lt;/code&gt; or just &lt;code&gt;avg()&lt;/code&gt;. Although⁣ &lt;code&gt;max()&lt;/code&gt; is probably better, because we are interested in the "worst" indicators.&lt;/p&gt;

&lt;p&gt;Okay, now that we’ve figured that out, let’s get started on the dashboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cluster status
&lt;/h3&gt;

&lt;p&gt;Here, I would like to see all three values — Green, Yellow, and Red — on a single Stats panel.&lt;/p&gt;

&lt;p&gt;But since we don’t have if/else in Grafana, let’s make a workaround.&lt;/p&gt;

&lt;p&gt;We collect all three metrics and multiply the result of each by 1, 2, or 3:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sum(aws_es_cluster_status_green_maximum) by (domain_name) * 1 +
sum(aws_es_cluster_status_yellow_maximum) by (domain_name) * 2 +
sum(aws_es_cluster_status_red_maximum) by (domain_name) * 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzc81mf8o3pkmwcc4hrq0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzc81mf8o3pkmwcc4hrq0.png" width="787" height="393"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Accordingly, if &lt;code&gt;aws_es_cluster_status_green_maximum&lt;/code&gt; == 1, then 1 * 1 == 1, and &lt;code&gt;aws_es_cluster_status_yellow_maximum&lt;/code&gt; == 0 and &lt;code&gt;aws_es_cluster_status_red_maximum&lt;/code&gt; will be == 0, then the multiplication will return 0.&lt;/p&gt;

&lt;p&gt;And if &lt;code&gt;aws_es_cluster_status_green_maximum&lt;/code&gt; becomes 0, but &lt;code&gt;aws_es_cluster_status_red_maximum&lt;/code&gt; is 1, then 1 * 2 equals 3, and based on the value 3, we will change the indicator in the Stats panel.&lt;/p&gt;

&lt;p&gt;And add Value mappings with text and colors:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdutc1ycrgk5ejczp2s71.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdutc1ycrgk5ejczp2s71.png" width="800" height="275"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Get the following result:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F298%2F0%2AqPS7pp_v22WMI59M.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F298%2F0%2AqPS7pp_v22WMI59M.png" width="298" height="157"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Nodes status
&lt;/h3&gt;

&lt;p&gt;It’s simple here — we know the required number, and we get the current one from &lt;code&gt;aws_es_nodes_maximum&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sum(aws_es_nodes_maximum) by (domain_name)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And again, using Value mappings, we set the values and colors:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F942%2F0%2Au0JXfDp8RVT58PJ9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F942%2F0%2Au0JXfDp8RVT58PJ9.png" width="800" height="280"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In case we ever increase the number of nodes and forget to update the value for “OK” here, we add a third status, ERR:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F295%2F0%2AQAPAAXBwrGQiX9nF.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F295%2F0%2AQAPAAXBwrGQiX9nF.png" width="295" height="153"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  CPUUtilization: Stats
&lt;/h3&gt;

&lt;p&gt;Here, we will make a cross-tabulation with the Gauge visualization type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;avg(aws_es_cpuutilization_average) by (domain_name)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set Text size and Unit:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2Ahl6JhgYzwq82g6Zv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2Ahl6JhgYzwq82g6Zv.png" width="800" height="286"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And Thresholds:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F315%2F0%2A_VkW9lFBRDpHdy7c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F315%2F0%2A_VkW9lFBRDpHdy7c.png" width="315" height="275"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Description ChatGPT generates pretty well — useful for developers and for us in six months, or we can just take the description from &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/managedomains-cloudwatchmetrics.html" rel="noopener noreferrer"&gt;AWS documentation&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;The percentage of CPU usage for data nodes in the cluster. Maximum shows the node with the highest CPU usage. Average represents all nodes in the cluster.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Add the rest of the stats:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2A2-ABqeGgk62ZpgzK.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2A2-ABqeGgk62ZpgzK.png" width="800" height="54"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  CPUUtilization: Graph
&lt;/h3&gt;

&lt;p&gt;Here we will display a graph for the CPU of each node — the average over 5 minutes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;max(avg_over_time(aws_es_cpuutilization_average[5m])) by (node_id)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And here is another example of how &lt;code&gt;sum()&lt;/code&gt; created spikes that did not actually exist:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F755%2F0%2Ad3d4p1Jgf4DcvUxi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F755%2F0%2Ad3d4p1Jgf4DcvUxi.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Therefore, we do &lt;code&gt;max()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Set Gradient mode == Opacity, and Unit == percent:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F319%2F0%2AbBspuCXjuIRW-lzr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F319%2F0%2AbBspuCXjuIRW-lzr.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Set Color scheme and Thresholds, enable Show thresholds:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F319%2F0%2AaHWTo0i2iquBAPWB.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F319%2F0%2AaHWTo0i2iquBAPWB.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In Data links, you can set a link to the DataNode Health page in the AWS Console:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://us-east-1.console.aws.amazon.com/aos/home?region=us-east-1#opensearch/domains/atlas-kb-prod-cluster/data_Node/${__field.labels.node_id}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All available fields — Ctrl+Space:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F895%2F0%2ABhUGuRh-PWrpR3y5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F895%2F0%2ABhUGuRh-PWrpR3y5.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Actions seems to have appeared not so long ago. I haven’t used it yet, but it looks interesting — you can push something:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F721%2F0%2Aze1aDaM1wMfEsK_8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F721%2F0%2Aze1aDaM1wMfEsK_8.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  JVMMemoryPressure: Graph
&lt;/h3&gt;

&lt;p&gt;Here, we are interested in seeing whether memory usage “sticks” and how often the Garbage Collector is launched.&lt;/p&gt;

&lt;p&gt;The query is simple  —  you can do &lt;code&gt;max by (node_id)&lt;/code&gt;, but I just made a general picture for the cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;max(aws_es_jvmmemory_pressure_average)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the schedule is similar to the previous one:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AhDfHdXlfuZFf1uTk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AhDfHdXlfuZFf1uTk.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In Description, add the explanation “when to worry”:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Represents the percentage of JVM heap in use (young + old generation).&lt;br&gt;&lt;br&gt;
Values below 75% are normal. Sustained pressure above 80% indicates frequent GC and potential performance degradation.&lt;br&gt;&lt;br&gt;
Values consistently &amp;gt; 85–90% mean heap exhaustion risk and may trigger ClusterIndexWritesBlocked — investigate immediately.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  JVMGCYoungCollectionCount and JVMGCOldCollectionCount
&lt;/h3&gt;

&lt;p&gt;A very useful graph to see how often Garbage Collects are triggered.&lt;/p&gt;

&lt;p&gt;In the query, we will use &lt;code&gt;increase[1m]&lt;/code&gt; to see how the value has changed in a minute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;max(increase(aws_es_jvmgcyoung_collection_count_average[1m])) by (domain_name)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And for Old Gen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;max(increase(aws_es_jvmgcold_collection_count_average[1m])) by (domain_name)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unit — ops/sec, Decimals set to 0 to have only integer values:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2APZEI5qtvVhCrt6Cm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2APZEI5qtvVhCrt6Cm.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  KNNHitCount vs KNNMissCount
&lt;/h3&gt;

&lt;p&gt;Here, we will generate data for a second  — &lt;code&gt; rate()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sum(rate(aws_es_knnhit_count_average[5m]))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And for Cache Miss:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sum(rate(aws_es_knnmiss_count_average[5m]))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unit ops/s, colors can be set via Overrides:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AEF1XXyg0ZtE4myo1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AEF1XXyg0ZtE4myo1.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The statistics here, by the way, are very mediocre — there are consistently a lot of cache misses, but we haven’t figured out why yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Final result
&lt;/h3&gt;

&lt;p&gt;We collect all the graphs and get something like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2A-4Ld9JO_sTGhf8_o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2A-4Ld9JO_sTGhf8_o.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;t3.small.search&lt;/code&gt; vs &lt;code&gt;t3.medium.search&lt;/code&gt; on graphs
&lt;/h3&gt;

&lt;p&gt;And here’s an example of how a lack of resources, primarily memory, is reflected in the graphs: we had &lt;code&gt;t3.medium.search&lt;/code&gt;, then we switched back to &lt;code&gt;t3.small.search&lt;/code&gt; to see how it would affect performance.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;t3.small.search&lt;/code&gt; is only 2 gigabytes of memory and 2 CPU cores.&lt;/p&gt;

&lt;p&gt;Of these 2 gigabytes of memory, 1 gigabyte was allocated to JVM Heap, 500 megabytes to k-NN memory, and 500 remained for other processes.&lt;/p&gt;

&lt;p&gt;Well, the results are quite expected:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F771%2F0%2AaT6RWS0ZdNDE8_9X.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F771%2F0%2AaT6RWS0ZdNDE8_9X.png" width="771" height="953"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Garbage Collectors started running constantly because it was necessary to clean up the memory that was lacking.&lt;/li&gt;
&lt;li&gt;Read IOPS increased because data was constantly being loaded from the disk to the JVM Heap Young and k-NN.&lt;/li&gt;
&lt;li&gt;Search Latency increased because not all data was in the cache, and I/O operations from the disk were pending.&lt;/li&gt;
&lt;li&gt;and CPU utilization jumped — because the CPU was loaded with Garbage Collectors and reading from the disk&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Creating Alerts
&lt;/h3&gt;

&lt;p&gt;You can also check out the recommendations from &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/cloudwatch-alarms.html" rel="noopener noreferrer"&gt;AWS — Recommended CloudWatch alarms for Amazon OpenSearch Service&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;OpenSearch ClusterStatus Yellow and OpenSearch ClusterStatus Red: here, simply if more than 0:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
      - alert: OpenSearch ClusterStatus Yellow
        expr: sum(aws_es_cluster_status_yellow_maximum) by (domain_name, node_id) &amp;gt; 0
        for: 1s
        labels:
          severity: warning
          component: backend
          environment: prod
        annotations:
          summary: 'OpenSearch ClusterStatus Yellow status detected'
          description: |-
            The primary shards for all indexes are allocated to nodes in the cluster, but replica shards for at least one index are not
            *OpenSearch Doman*: `{{ "{{" }} $labels.domain_name }}`
          grafana_opensearch_overview_url: 'https://{{ .Values.monitoring.root_url }}/d/b2d2dabd-a6b4-4a8a-b795-270b3e200a2e/aws-opensearch-cluster-cloudwatch'

      - alert: OpenSearch ClusterStatus Red
        expr: sum(aws_es_cluster_status_red_maximum) by (domain_name, node_id) &amp;gt; 0
        for: 1s
        labels:
          severity: critical
          component: backend
          environment: prod
        annotations:
          summary: 'OpenSearch ClusterStatus RED status detected!'
          description: |-
            The primary and replica shards for at least one index are not allocated to nodes in the cluster
            *OpenSearch Doman*: `{{ "{{" }} $labels.domain_name }}`
          grafana_opensearch_overview_url: 'https://{{ .Values.monitoring.root_url }}/d/b2d2dabd-a6b4-4a8a-b795-270b3e200a2e/aws-opensearch-cluster-cloudwatch'
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Through &lt;code&gt;labels&lt;/code&gt;, we have implemented alert routing in Opsgenie to the necessary Slack channels, and the annotation &lt;code&gt;grafana_opensearch_overview_url&lt;/code&gt; is used to add a link to Grafana in a Slack message:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F380%2F0%2A3tdmffpqyMNR-vjW.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F380%2F0%2A3tdmffpqyMNR-vjW.png" width="380" height="291"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;OpenSearch CPUHigh — if more than 20% for 10 minutes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- alert: OpenSearch CPUHigh
        expr: sum(aws_es_cpuutilization_average) by (domain_name, node_id) &amp;gt; 20
        for: 10m
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;OpenSearch Data Node down — if the node is down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- alert: OpenSearch Data Node down
        expr: sum(aws_es_nodes_maximum) by (domain_name) &amp;lt; 3
        for: 1s
        labels:
          severity: critical
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;aws_es_free_storage_space_maximum&lt;/code&gt; - we don't need it yet.&lt;/p&gt;

&lt;p&gt;OpenSearch Blocking Write — alert us if write blocks have started:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
      - alert: OpenSearch Blocking Write
        expr: sum(aws_es_cluster_index_writes_blocked_maximum) by (domain_name) &amp;gt;= 1
        for: 1s
        labels:
          severity: critical
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the rest of the alerts I’ve added so far:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
      - alert: OpenSearch AutomatedSnapshotFailure 
        expr: sum(aws_es_automated_snapshot_failure_maximum) by (domain_name) &amp;gt;= 1
        for: 1s
        labels:
          severity: critical
...
      - alert: OpenSearch 5xx Errors 
        expr: sum(aws_es_5xx_maximum) by (domain_name) &amp;gt;= 1
        for: 1s
        labels:
          severity: critical
...
      - alert: OpenSearch IopsThrottled
        expr: sum(aws_es_iops_throttle_maximum) by (domain_name) &amp;gt;= 1
        for: 1s
        labels:
          severity: warning
...
      - alert: OpenSearch ThroughputThrottled
        expr: sum(aws_es_throughput_throttle_maximum) by (domain_name) &amp;gt;= 1
        for: 1s
        labels:
          severity: warning
...
      - alert: OpenSearch SysMemoryUtilization High Warning
        expr: avg(aws_es_sys_memory_utilization_average) by (domain_name) &amp;gt;= 95
        for: 5m
        labels:
          severity: warning
...
      - alert: OpenSearch PrimaryWriteRejected High
        expr: sum(aws_es_primary_write_rejected_maximum) by (domain_name) &amp;gt;= 1
        for: 1s
        labels:
          severity: critical
...
      - alert: OpenSearch KNNGraphQueryErrors High
        expr: sum(aws_es_knngraph_query_errors_maximum) by (domain_name) &amp;gt;= 1
        for: 1s
        labels:
          severity: critical
...
      - alert: OpenSearch KNNCacheCapacityReached
        expr: sum(aws_es_knngraph_query_errors_maximum) by (domain_name) &amp;gt;= 1
        for: 1s
        labels:
          severity: warning
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As we use it, we’ll see what else we can add.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;/em&gt; &lt;a href="https://rtfm.co.ua/en/aws-monitoring-aws-opensearch-service-cluster-with-cloudwatch/" rel="noopener noreferrer"&gt;&lt;em&gt;RTFM: Linux, DevOps, and system administration&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>aws</category>
      <category>monitoring</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Terraform: creating an AWS OpenSearch Service cluster and users</title>
      <dc:creator>Arseny Zinchenko</dc:creator>
      <pubDate>Tue, 30 Dec 2025 10:00:00 +0000</pubDate>
      <link>https://dev.to/aws-heroes/terraform-creating-an-aws-opensearch-service-cluster-and-users-4786</link>
      <guid>https://dev.to/aws-heroes/terraform-creating-an-aws-opensearch-service-cluster-and-users-4786</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frdcgbe0zxi1aeku3sf31.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frdcgbe0zxi1aeku3sf31.png" width="480" height="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the first part, we covered the basics of AWS OpenSearch Service in general and the types of instances for Data Nodes — &lt;a href="https://rtfm.co.ua/en/aws-introduction-to-the-opensearch-service-as-a-vector-store/" rel="noopener noreferrer"&gt;AWS: Getting Started with OpenSearch Service as a Vector Store&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In the second part, we covered access, &lt;a href="https://rtfm.co.ua/en/aws-creating-an-opensearch-service-cluster-and-configuring-authentication-and-authorization/" rel="noopener noreferrer"&gt;AWS: Creating an OpenSearch Service Cluster and Configuring Authentication and Authorization&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Now let’s write Terraform code to create a cluster, users, and indexes.&lt;/p&gt;

&lt;p&gt;We will create the cluster in VPC and use the internal user database for authentication.&lt;/p&gt;

&lt;p&gt;But in VPC, you can’t… Because — surprise! — AWS Bedrock requires OpenSearch Managed Cluster to be public, not in VPC.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;The OpenSearch Managed Cluster you provided is not supported because it is VPC protected. Your cluster must be behind a public network.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I wrote to the AWS tech. support, and they said:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;However, there is an ongoing product feature request (PFR) to have Bedrock KnowledgeBases support provisioned Open Search clusters in VPC.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And they suggest using Amazon OpenSearch Serverless, which we are actually running away from because the prices are ridiculous.&lt;/p&gt;

&lt;p&gt;The second problem that arose when I started writing resources &lt;a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/bedrockagent_knowledge_base" rel="noopener noreferrer"&gt;&lt;code&gt;bedrockagent_knowledge_base&lt;/code&gt;&lt;/a&gt; is that it does not support &lt;code&gt;storage_configuration with type&lt;/code&gt;OPENSEARCH_MANAGED`, only Serverless.&lt;/p&gt;

&lt;p&gt;But &lt;a href="https://github.com/hashicorp/terraform-provider-aws/pull/44060" rel="noopener noreferrer"&gt;Pull Request for this already exists&lt;/a&gt;, maybe someday they will approve it.&lt;br&gt;
&lt;em&gt;(&lt;em&gt;UPD&lt;/em&gt;: this was already merged)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So, we will create an OpenSearch Managed Service cluster with three indexes  —  Dev/Staging/Prod.&lt;/p&gt;

&lt;p&gt;The cluster will have three small data nodes, and each index will have 1 primary shard and 1 replica, because the project is small, and the data in our Production index on AWS OpenSearch Serverless, from which we want to migrate to AWS OpenSearch Service, is currently only 2 GiB, and is unlikely to grow significantly in the future.&lt;/p&gt;

&lt;p&gt;It would be good to create the cluster in our own Terraform module to make it easier to create some test environments, as I did for AWS EKS, but there isn’t much time for that right now, so we’ll just use tf files with a separate &lt;code&gt;prod.tfvars&lt;/code&gt; for variables.&lt;/p&gt;

&lt;p&gt;Maybe later I’ll write separately about transferring it to our own module, because it’s really convenient.&lt;/p&gt;

&lt;p&gt;In the next part, we’ll talk about monitoring, because our Production has already crashed once :-)&lt;/p&gt;

&lt;h3&gt;
  
  
  Contents
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Terraform files structure&lt;/li&gt;
&lt;li&gt;Project planning&lt;/li&gt;
&lt;li&gt;Creating a cluster&lt;/li&gt;
&lt;li&gt;Custom endpoint configuration&lt;/li&gt;
&lt;li&gt;Terraform Outputs&lt;/li&gt;
&lt;li&gt;Creating OpenSearch Users&lt;/li&gt;
&lt;li&gt;Error: elastic: Error 403 (Forbidden)&lt;/li&gt;
&lt;li&gt;Creating Internal Users&lt;/li&gt;
&lt;li&gt;Internal database users&lt;/li&gt;
&lt;li&gt;Adding IAM Users&lt;/li&gt;
&lt;li&gt;Creating AWS Bedrock IAM Roles and OpenSearch Role mappings&lt;/li&gt;
&lt;li&gt;Creating OpenSearch indexes&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Terraform files structure
&lt;/h3&gt;

&lt;p&gt;The initial file and directory structure of the project is as follows:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;br&gt;
$ tree .&lt;br&gt;
.&lt;br&gt;
├── README.md&lt;br&gt;
└── terraform&lt;br&gt;
    ├── Makefile&lt;br&gt;
    ├── backend.tf&lt;br&gt;
    ├── data.tf&lt;br&gt;
    ├── envs&lt;br&gt;
    │ └── prod&lt;br&gt;
    │ └── prod.tfvars&lt;br&gt;
    ├── locals.tf&lt;br&gt;
    ├── outputs.tf&lt;br&gt;
    ├── providers.tf&lt;br&gt;
    ├── variables.tf&lt;br&gt;
    └── versions.tf&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;In the &lt;code&gt;providers.tf&lt;/code&gt; - provider settings, currently only AWS, and through it we set the default tags:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;br&gt;
provider "aws" {&lt;br&gt;
  region = var.aws_region&lt;br&gt;
  default_tags {&lt;br&gt;
    tags = {&lt;br&gt;
      component = var.component&lt;br&gt;
      created-by = "terraform"&lt;br&gt;
      environment = var.environment&lt;br&gt;
    }&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;In the &lt;code&gt;data.tf&lt;/code&gt;, we collect AWS Account ID, Availability Zones, VPC, and private subnets in which we will create a cluster in which we will eventually create a cluster:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
data "aws_caller_identity" "current" {}&lt;/p&gt;

&lt;p&gt;data "aws_availability_zones" "available" {&lt;br&gt;
  state = "available"&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;data "aws_vpc" "eks_vpc" {&lt;br&gt;
  id = var.vpc_id&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;data "aws_subnets" "private" {&lt;br&gt;
  filter {&lt;br&gt;
    name = "vpc-id"&lt;br&gt;
    values = [var.vpc_id]&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;tags = {&lt;br&gt;
    subnet-type = "private"&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;File &lt;code&gt;variables.tf&lt;/code&gt; with our default variables, then we will add new ones:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
variable "aws_region" {&lt;br&gt;
  type = string&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;variable "project_name" {&lt;br&gt;
  description = "A project name to be used in resources"&lt;br&gt;
  type = string&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;variable "component" {&lt;br&gt;
  description = "A team using this project (backend, web, ios, data, devops)"&lt;br&gt;
  type = string&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;variable "environment" {&lt;br&gt;
  description = "Dev/Prod, will be used in AWS resources Name tag, and resources names"&lt;br&gt;
  type = string&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;variable "vpc_id" {&lt;br&gt;
  type = string&lt;br&gt;
  description = "A VPC ID to be used to create OpenSearch cluster and its Nodes"&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Pass the values of variables through a separate &lt;code&gt;prod.tfvars&lt;/code&gt; file, then, if necessary, we can create a new environment through a file of the type &lt;code&gt;envs/test/test.tfvars&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;br&gt;
aws_region = "us-east-1"&lt;br&gt;
project_name = "atlas-kb"&lt;br&gt;
component = "backend"&lt;br&gt;
environment = "prod"&lt;br&gt;
vpc_id = "vpc-0fbaffe234c0d81ea"&lt;br&gt;
dns_zone = "prod.example.co"&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;In the &lt;code&gt;Makefile&lt;/code&gt;, we simplify our local life:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;h3&gt;
  
  
  PROD
&lt;/h3&gt;

&lt;p&gt;init-prod:&lt;br&gt;
  terraform init -reconfigure -backend-config="key=prod/atlas-knowledge-base-prod.tfstate"&lt;/p&gt;

&lt;p&gt;plan-prod:&lt;br&gt;
  terraform plan -var-file=envs/prod/prod.tfvars&lt;/p&gt;

&lt;p&gt;apply-prod:&lt;br&gt;
  terraform apply -var-file=envs/prod/prod.tfvars&lt;/p&gt;

&lt;h1&gt;
  
  
  destroy-prod:
&lt;/h1&gt;

&lt;h1&gt;
  
  
  terraform destroy -var-file=envs/prod/prod.tfvars
&lt;/h1&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;What files will be next?&lt;/p&gt;

&lt;p&gt;We will also have AWS Bedrock, which will need to be configured for access — we will do this through its IAM Role, and I will not write about Bedrock here — because it is a separate topic, and Terraform does not yet support &lt;code&gt;OPENSEARCH_MANAGED&lt;/code&gt;, so we did it manually, and then we will execute &lt;a href="https://rtfm.co.ua/en/terraform-using-import-and-some-hiden-pitfalls/" rel="noopener noreferrer"&gt;terraform import&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;We will create indexes, users for our Backend API, and Bedrock IAM Role mappings in OpenSearch’s internal database through Terraform OpenSearch Provider to simplify OpenSearch Dashboards access.&lt;/p&gt;

&lt;h3&gt;
  
  
  Project planning
&lt;/h3&gt;

&lt;p&gt;We can create a cluster from the Terraform resource &lt;a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/opensearch_domain" rel="noopener noreferrer"&gt;&lt;code&gt;aws_opensearch_domain&lt;/code&gt;&lt;/a&gt;, or we can use ready-made modules, such as the &lt;a href="https://registry.terraform.io/modules/terraform-aws-modules/opensearch/aws/latest" rel="noopener noreferrer"&gt;opensearch&lt;/a&gt; from &lt;a href="https://www.linkedin.com/in/antonbabenko/" rel="noopener noreferrer"&gt;@Anton Babenko&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Let’s take Anton’s module, because I use his modules a lot, and everything works great.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating a cluster
&lt;/h3&gt;

&lt;p&gt;Examples — &lt;a href="https://github.com/terraform-aws-modules/terraform-aws-opensearch/tree/master/examples" rel="noopener noreferrer"&gt;terraform-aws-opensearch/tree/master/examples&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Add a variable with cluster parameters to the &lt;code&gt;variables.tf&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
...&lt;/p&gt;

&lt;p&gt;variable "cluser_options" {&lt;br&gt;
  description = "A map of options to configure the OpenSearch cluster"&lt;br&gt;
  type = object({&lt;br&gt;
    instance_type = string&lt;br&gt;
    instance_count = number&lt;br&gt;
    volume_size = number&lt;br&gt;
    volume_type = string&lt;br&gt;
    engine_version = string&lt;br&gt;
    auto_software_update_enabled = bool&lt;br&gt;
  })&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;And a value in &lt;code&gt;prod.tfvars&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
...&lt;/p&gt;

&lt;p&gt;cluser_options = {&lt;br&gt;
  instance_type = "t3.small.search"&lt;br&gt;
  instance_count = 3&lt;br&gt;
  volume_size = 50&lt;br&gt;
  volume_type = "gp3"&lt;br&gt;
  engine_version = "OpenSearch_2.19"&lt;br&gt;
  auto_software_update_enabled = true&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;t3.small.search&lt;/code&gt; instances are the most minimal and sufficient for us at this time, although there are limitations for &lt;code&gt;t3&lt;/code&gt;, such as the &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/auto-tune.html" rel="noopener noreferrer"&gt;AWS OpenSearch Auto-tune&lt;/a&gt; feature not being supported.&lt;/p&gt;

&lt;p&gt;In general, &lt;code&gt;t3&lt;/code&gt; is not intended for production use cases. See also &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/bp.html" rel="noopener noreferrer"&gt;Operational best practices for Amazon OpenSearch Service&lt;/a&gt;, &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/supported-instance-types.html#latest-gen" rel="noopener noreferrer"&gt;Current generation instance types&lt;/a&gt;, and &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/limits.html" rel="noopener noreferrer"&gt;Amazon OpenSearch Service quotas&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I set the version here to 2.9, but 3.1 was added just a few days ago — see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/what-is.html#choosing-version" rel="noopener noreferrer"&gt;Supported versions of Elasticsearch and OpenSearch&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;We take three nodes so that the cluster can select a cluster manager node if one node fails, see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/managedomains-multiaz.html" rel="noopener noreferrer"&gt;Dedicated master node distribution&lt;/a&gt;, &lt;a href="https://www.instaclustr.com/blog/learning-opensearch-from-scratch-part-2-digging-deeper/" rel="noopener noreferrer"&gt;Learning OpenSearch from scratch, part 2: Digging deeper&lt;/a&gt;, and &lt;a href="https://aws.amazon.com/blogs/big-data/enhance-stability-with-dedicated-cluster-manager-nodes-using-amazon-opensearch-service/" rel="noopener noreferrer"&gt;Enhance stability with dedicated cluster manager nodes using Amazon OpenSearch Service&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Contents of the &lt;code&gt;locals.tf&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;br&gt;
locals {&lt;br&gt;
  # 'atlas-kb-prod'&lt;br&gt;
  env_name = "${var.project_name}-${var.environment}"&lt;br&gt;
}&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Most of the &lt;code&gt;locals&lt;/code&gt; will be right here, but some that are very "local" to a particular code will be in the resource code files.&lt;/p&gt;

&lt;p&gt;Add the file &lt;code&gt;opensearcth_users.tf&lt;/code&gt; - for now, there is only a root user here, and the password is stored in AWS Parameter Store (instead of AWS Secrets Manager - "that's just how it happened historically“):&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;h3&gt;
  
  
  ROOT
&lt;/h3&gt;

&lt;h1&gt;
  
  
  generate root password
&lt;/h1&gt;

&lt;h1&gt;
  
  
  waiting for write-only: &lt;a href="https://github.com/hashicorp/terraform-provider-aws/pull/43621" rel="noopener noreferrer"&gt;https://github.com/hashicorp/terraform-provider-aws/pull/43621&lt;/a&gt;
&lt;/h1&gt;

&lt;h1&gt;
  
  
  then will update it with the ephemeral type
&lt;/h1&gt;

&lt;p&gt;resource "random_password" "os_master_password" {&lt;br&gt;
  length = 16&lt;br&gt;
  special = true&lt;br&gt;
}&lt;/p&gt;

&lt;h1&gt;
  
  
  store the root password in AWS Parameter Store
&lt;/h1&gt;

&lt;p&gt;resource "aws_ssm_parameter" "os_master_password" {&lt;br&gt;
  name = "/${var.environment}/${local.env_name}-root-password"&lt;br&gt;
  description = "OpenSearch cluster master password"&lt;br&gt;
  type = "SecureString"&lt;br&gt;
  value = random_password.os_master_password.result&lt;br&gt;
  overwrite = true&lt;br&gt;
  tier = "Standard"&lt;/p&gt;

&lt;p&gt;lifecycle {&lt;br&gt;
    ignore_changes = [value] # to prevent diff every time password is regenerated&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;data "aws_ssm_parameter" "os_master_password" {&lt;br&gt;
  name = "/${var.environment}/${local.env_name}-root-password"&lt;br&gt;
  with_decryption = true&lt;/p&gt;

&lt;p&gt;depends_on = [aws_ssm_parameter.os_master_password]&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Let’s write the &lt;code&gt;opensearch_cluster.tf&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;I left the config for VPC here for future reference and just as an example, although it will not be possible to transfer an already created cluster to VPC — you will have to create a new one, see &lt;strong&gt;Limitations&lt;/strong&gt; in the documentation &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/vpc.html#vpc-limitations" rel="noopener noreferrer"&gt;Launching your Amazon OpenSearch Service domains within a VPC&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
module "opensearch" {&lt;br&gt;
  source = "terraform-aws-modules/opensearch/aws"&lt;br&gt;
  version = "~&amp;gt; 2.0.0"  &lt;/p&gt;

&lt;p&gt;# enable Fine-grained access control&lt;br&gt;
  # by using the internal user database, we'll simply access to the Dashboards&lt;br&gt;
  # for backend API Kubernetes Pods, will use Kubernetes Secrets with username:password from AWS Parameter Store&lt;br&gt;
  advanced_security_options = {&lt;br&gt;
    enabled = true&lt;br&gt;
    anonymous_auth_enabled = false&lt;br&gt;
    internal_user_database_enabled = true&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;master_user_options = {
  master_user_name = "os_root"
  master_user_password = data.aws_ssm_parameter.os_master_password.value
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;# can't be used with t3 instances&lt;br&gt;
  auto_tune_options = {&lt;br&gt;
    desired_state = "DISABLED"&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;# have three data nodes - t3.small.search nodes in two AZs&lt;br&gt;
  # will use 3 indexes - dev/stage/prod with 1 shard and 1 replica each&lt;br&gt;
  cluster_config = {&lt;br&gt;
    instance_count = var.cluser_options.instance_count&lt;br&gt;
    dedicated_master_enabled = false&lt;br&gt;
    instance_type = var.cluser_options.instance_type&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# put both data-nodes in different AZs
zone_awareness_config = {
  availability_zone_count = 2
}

zone_awareness_enabled = true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;# the cluster's name&lt;br&gt;
  # 'atlas-kb-prod'&lt;br&gt;
  domain_name = "${local.env_name}-cluster"&lt;/p&gt;

&lt;p&gt;# 50 GiB for each Data Node&lt;br&gt;
  ebs_options = {&lt;br&gt;
    ebs_enabled = true&lt;br&gt;
    volume_type = var.cluser_options.volume_type&lt;br&gt;
    volume_size = var.cluser_options.volume_size&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;encrypt_at_rest = {&lt;br&gt;
    enabled = true&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;# latest for today:&lt;br&gt;
  # &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/what-is.html#choosing-version" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/opensearch-service/latest/developerguide/what-is.html#choosing-version&lt;/a&gt;&lt;br&gt;
  engine_version = var.cluser_options.engine_version&lt;/p&gt;

&lt;p&gt;# enable CloudWatch logs for Index and Search slow logs&lt;br&gt;
  # TODO: collect to VictoriaLogs or Loki, and create metrics and alerts&lt;br&gt;
  log_publishing_options = [&lt;br&gt;
    { log_type = "INDEX_SLOW_LOGS" },&lt;br&gt;
    { log_type = "SEARCH_SLOW_LOGS" },&lt;br&gt;
  ]&lt;/p&gt;

&lt;p&gt;ip_address_type = "ipv4"&lt;/p&gt;

&lt;p&gt;node_to_node_encryption = {&lt;br&gt;
    enabled = true&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;# allow minor version updates automatically&lt;br&gt;
  # will be performed during off-peak windows&lt;br&gt;
  software_update_options = {&lt;br&gt;
    auto_software_update_enabled = var.cluser_options.auto_software_update_enabled&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;# DO NOT use 'atlas-vpc-ops' VPC and its private subnets&lt;br&gt;
  # &amp;gt; "The OpenSearch Managed Cluster you provided is not supported because it is VPC protected. Your cluster must be behind a public network."&lt;br&gt;
  # vpc_options = {&lt;br&gt;
  # subnet_ids = data.aws_subnets.private.ids&lt;br&gt;
  # }&lt;/p&gt;

&lt;p&gt;# # VPC endpoint to access from Kubernetes Pods&lt;br&gt;
  # vpc_endpoints = {&lt;br&gt;
  # one = {&lt;br&gt;
  # subnet_ids = data.aws_subnets.private.ids&lt;br&gt;
  # }&lt;br&gt;
  # }&lt;/p&gt;

&lt;p&gt;# Security Group rules to allow access from the VPC only&lt;br&gt;
  # security_group_rules = {&lt;br&gt;
  # ingress_443 = {&lt;br&gt;
  # type = "ingress"&lt;br&gt;
  # description = "HTTPS access from VPC"&lt;br&gt;
  # from_port = 443&lt;br&gt;
  # to_port = 443&lt;br&gt;
  # ip_protocol = "tcp"&lt;br&gt;
  # cidr_ipv4 = data.aws_vpc.ops_vpc.cidr_block&lt;br&gt;
  # }&lt;br&gt;
  # }&lt;/p&gt;

&lt;p&gt;# Access policy&lt;br&gt;
  # necessary to allow access for AWS user to the Dashboards&lt;br&gt;
  access_policy_statements = [&lt;br&gt;
    {&lt;br&gt;
      effect = "Allow"&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  principals = [{
    type = "*"
    identifiers = ["*"]
  }]

  actions = ["es:*"]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;]&lt;/p&gt;

&lt;p&gt;# 'atlas-kb-ops-os-cluster'&lt;br&gt;
  tags = {&lt;br&gt;
    Name = "${var.project_name}-${var.environment}-os-cluster"&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Basically, everything is described in the comments, but in short:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;enable &lt;a href="https://rtfm.co.ua/en/aws-creating-an-opensearch-service-cluster-and-configuring-authentication-and-authorization/#Fine-grained_access_control" rel="noopener noreferrer"&gt;fine-grained access control&lt;/a&gt; and a local user database&lt;/li&gt;
&lt;li&gt;three data nodes, each with 50 gigabytes of disk space, in different Availability Zones&lt;/li&gt;
&lt;li&gt;enable logs in CloudWatch&lt;/li&gt;
&lt;li&gt;create a cluster in private subnets&lt;/li&gt;
&lt;li&gt;allow access for everyone in the Domain Access Policy&lt;/li&gt;
&lt;li&gt;well, that’s it for now… we can’t use Security Groups because we’re not in VPC, but how do we create an IP-based policy? We don’t know CIDR Bedrock&lt;/li&gt;
&lt;li&gt;or in the &lt;code&gt;principals.identifiers&lt;/code&gt; we could add a limit on our IAM Users + Bedrock AIM Role&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run creating the cluster and go to have some tea, as this process will take around 20 minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Custom endpoint configuration
&lt;/h3&gt;

&lt;p&gt;After creating the cluster, check access to the Dashboards. If everything is OK, add a custom endpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Note&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;: Custom endpoints have their own quirks: in Terraform OpenSearch Provider, you need to use the custom endpoint URL, but in AWS Bedrock Knowledge Base, you need to use the default cluster URL.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;To do this, we need to create a certificate in AWS Certificate Manager, and add a new record in Route53.&lt;/p&gt;

&lt;p&gt;I expected a possible chicken-and-egg problem here, because Custom Endpoint settings depend on AWS ACM and a record in AWS Route53, and the record in AWS Route53 will depend on the cluster because it uses its endpoint.&lt;/p&gt;

&lt;p&gt;But no, if you create a new cluster with the settings described below, everything is created correctly: first, the certificate in AWS ACM, then the cluster with Custom Endpoint, then the record in Route53 with CNAME to the cluster default URL.&lt;/p&gt;

&lt;p&gt;Add a new &lt;code&gt;local&lt;/code&gt; - &lt;code&gt;os_custom_domain_name&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;br&gt;
locals {&lt;br&gt;
  # 'atlas-kb-prod'&lt;br&gt;
  env_name = "${var.project_name}-${var.environment}"&lt;br&gt;
  # 'opensearch.prod.example.co'&lt;br&gt;
  os_custom_domain_name = "opensearch.${var.dns_zone}"&lt;br&gt;
}&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Add the Route53 zone data retrieval to the &lt;code&gt;data.tf&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
...&lt;/p&gt;

&lt;p&gt;data "aws_route53_zone" "zone" {&lt;br&gt;
  name = var.dns_zone&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Add certificate creation and Route53 entry to the &lt;code&gt;opensearch_cluster.tf&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;h1&gt;
  
  
  TLS for the Custom Domain
&lt;/h1&gt;

&lt;p&gt;module "prod_opensearch_acm" {&lt;br&gt;
  source = "terraform-aws-modules/acm/aws"&lt;br&gt;
  version = "~&amp;gt; 6.0"&lt;/p&gt;

&lt;p&gt;# 'opensearch.example.co'&lt;br&gt;
  domain_name = local.os_custom_domain_name&lt;br&gt;
  zone_id = data.aws_route53_zone.zone.zone_id&lt;/p&gt;

&lt;p&gt;validation_method = "DNS"&lt;br&gt;
  wait_for_validation = true&lt;/p&gt;

&lt;p&gt;tags = {&lt;br&gt;
    Name = local.os_custom_domain_name&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;resource "aws_route53_record" "opensearch_domain_endpoint" {&lt;br&gt;
  zone_id = data.aws_route53_zone.zone.zone_id&lt;br&gt;
  name = local.os_custom_domain_name&lt;br&gt;
  type = "CNAME"&lt;br&gt;
  ttl = 300&lt;br&gt;
  records = [module.opensearch.domain_endpoint]&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;...&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;And in the &lt;code&gt;module "opensearch"&lt;/code&gt;, add the custom endpoint settings:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;br&gt;
...&lt;br&gt;
  domain_endpoint_options = {&lt;br&gt;
    custom_endpoint_certificate_arn = module.prod_opensearch_acm.acm_certificate_arn&lt;br&gt;
    custom_endpoint_enabled = true&lt;br&gt;
    custom_endpoint = local.os_custom_domain_name&lt;br&gt;
    tls_security_policy = "Policy-Min-TLS-1-2-2019-07"&lt;br&gt;
  }&lt;br&gt;
...&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Run &lt;code&gt;terraform init&lt;/code&gt; and &lt;code&gt;terraform apply&lt;/code&gt;, check the settings:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2ymu1102tbgsou4eqeu5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2ymu1102tbgsou4eqeu5.png" width="538" height="337"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And check access to the Dashboards.&lt;/p&gt;

&lt;h3&gt;
  
  
  Terraform Outputs
&lt;/h3&gt;

&lt;p&gt;Let’s add some outputs.&lt;/p&gt;

&lt;p&gt;For now, just for ourselves, but later we may use them in imports from other projects, see &lt;a href="https://rtfm.co.ua/en/terraform-terraform_remote_state-getting-outputs-from-other-state-files/" rel="noopener noreferrer"&gt;Terraform: terraform_remote_state — getting outputs from other state files&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
output "vpc_id" {&lt;br&gt;
  value = var.vpc_id&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;output "cluster_arn" {&lt;br&gt;
  value = module.opensearch.domain_arn&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;output "opensearch_domain_endpoint_cluster" {&lt;br&gt;
  value = "https://${module.opensearch.domain_endpoint}"&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;output "opensearch_domain_endpoint_custom" {&lt;br&gt;
  value = "https://${local.os_custom_domain_name}"&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;output "opensearch_root_username" {&lt;br&gt;
  value = "os_root"&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;output "opensearch_root_user_password_secret_name" {&lt;br&gt;
  value = "/${var.environment}/${local.env_name}-root-password"&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating OpenSearch Users
&lt;/h3&gt;

&lt;p&gt;All that’s left now are users and indexes.&lt;/p&gt;

&lt;p&gt;We will have two types of users:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;regular users from the OpenSearch internal database — for our Backend API in Kubernetes (actually, we later switched to IAM Roles, which are mapped to the Backend via &lt;a href="https://rtfm.co.ua/aws-eks-pod-identities-zamina-irsa-sproshhuyemo-menedzhment-iam-dostupiv/" rel="noopener noreferrer"&gt;EKS Pod Identities&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;and users (IAM Role) for Bedrock — there will be three Knowledge Bases, each with its own IAM Role, for which we will need to add an OpenSearch Role and map it to IAM roles&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let’s start with regular users.&lt;/p&gt;

&lt;p&gt;Add a provider, in my case it is in the &lt;code&gt;versions.tf&lt;/code&gt; file:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
terraform {&lt;/p&gt;

&lt;p&gt;required_version = "~&amp;gt; 1.6"&lt;/p&gt;

&lt;p&gt;required_providers {&lt;br&gt;
    aws = {&lt;br&gt;
      source = "hashicorp/aws"&lt;br&gt;
      version = "~&amp;gt; 6.0"&lt;br&gt;
    }&lt;br&gt;
    opensearch = {&lt;br&gt;
      source = "opensearch-project/opensearch"&lt;br&gt;
      version = "~&amp;gt; 2.3"&lt;br&gt;
    }&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;In the &lt;code&gt;providers.tf&lt;/code&gt; file, describe access to the cluster:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
...&lt;/p&gt;

&lt;p&gt;provider "opensearch" {&lt;br&gt;
  url = "https://${local.os_custom_domain_name}"&lt;br&gt;
  username = "os_root"&lt;br&gt;
  password = data.aws_ssm_parameter.os_master_password.value&lt;br&gt;
  healthcheck = false&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Error: elastic: Error 403 (Forbidden)
&lt;/h3&gt;

&lt;p&gt;Here is an important point about the &lt;code&gt;url&lt;/code&gt; value in the provider configuration. I wrote about it above, and now I will show you how it looks.&lt;/p&gt;

&lt;p&gt;First, in the &lt;code&gt;provider.url&lt;/code&gt;, I set it as &lt;code&gt;outputs&lt;/code&gt; of the module, i.e. &lt;code&gt;module.opensearch.domain_endpoint&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Because of this, I got a 403 error when I tried to create users:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;br&gt;
...&lt;br&gt;
opensearch_user.os_kraken_dev_user: Creating...&lt;br&gt;
opensearch_role.os_kraken_dev_role: Creating...&lt;br&gt;
╷&lt;br&gt;
│ Error: elastic: Error 403 (Forbidden)&lt;br&gt;
│ &lt;br&gt;
│ with opensearch_user.os_kraken_dev_user,&lt;br&gt;
│ on opensearch_users.tf line 23, in resource "opensearch_user" "os_kraken_dev_user":&lt;br&gt;
│ 23: resource "opensearch_user" "os_kraken_dev_user" {&lt;br&gt;
│ &lt;br&gt;
╵&lt;br&gt;
╷&lt;br&gt;
│ Error: elastic: Error 403 (Forbidden)&lt;br&gt;
│ &lt;br&gt;
│ with opensearch_role.os_kraken_dev_role,&lt;br&gt;
│ on opensearch_users.tf line 30, in resource "opensearch_role" "os_kraken_dev_role":&lt;br&gt;
│ 30: resource "opensearch_role" "os_kraken_dev_role" {&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Thus, set the URL in the form of FQDN, which we did for Custom Endpoint, something like &lt;code&gt;"url = https://opensearch.exmaple.com"&lt;/code&gt; - and everything works well.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating Internal Users
&lt;/h3&gt;

&lt;p&gt;Now for the users themselves.&lt;/p&gt;

&lt;p&gt;There will be three of them — &lt;em&gt;dev&lt;/em&gt;, &lt;em&gt;staging&lt;/em&gt;, &lt;em&gt;prod&lt;/em&gt;, each with access to the corresponding index.&lt;/p&gt;

&lt;p&gt;Here we will use &lt;a href="https://registry.terraform.io/providers/opensearch-project/opensearch/latest/docs/resources/user" rel="noopener noreferrer"&gt;&lt;code&gt;opensearch_user&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If the cluster is created in VPC, a VPN connection is required so that the provider can connect to the cluster.&lt;/p&gt;

&lt;p&gt;Add &lt;a href="https://rtfm.co.ua/en/terraform-introduction-to-data-types-primitives-and-complex/#list" rel="noopener noreferrer"&gt;list()&lt;/a&gt; to the &lt;code&gt;variables.tf&lt;/code&gt; with a list of environments:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
...&lt;/p&gt;

&lt;p&gt;variable "app_environments" {&lt;br&gt;
  type = list(string)&lt;br&gt;
  description = "The Application's environments, to be used to created Dev/Staging/Prod DynamoDB tables, etc"&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;And the value in &lt;code&gt;prod.tfvars&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
...&lt;/p&gt;

&lt;p&gt;app_environments = [&lt;br&gt;
  "dev",&lt;br&gt;
  "staging",&lt;br&gt;
  "prod"&lt;br&gt;
]&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Internal database users
&lt;/h3&gt;

&lt;p&gt;At first, I planned to just use local users, and wrote this option in this post — let it be. Next, I will show how we did it in the end — with IAM Users and IAM Roles.&lt;/p&gt;

&lt;p&gt;In the file &lt;code&gt;opensearch_users.tf&lt;/code&gt;, add three passwords, three users, and three roles to which we map users in loops - each role with access to its own index:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
...&lt;/p&gt;

&lt;h3&gt;
  
  
  KRAKEN
&lt;/h3&gt;

&lt;p&gt;resource "random_password" "os_kraken_password" {&lt;br&gt;
  for_each = toset(var.app_environments)&lt;br&gt;
  length = 16&lt;br&gt;
  special = true&lt;br&gt;
}&lt;/p&gt;

&lt;h1&gt;
  
  
  store the root password in AWS Parameter Store
&lt;/h1&gt;

&lt;p&gt;resource "aws_ssm_parameter" "os_kraken_password" {&lt;br&gt;
  for_each = toset(var.app_environments)&lt;/p&gt;

&lt;p&gt;name = "/${var.environment}/${local.env_name}-kraken-${each.key}-password"&lt;br&gt;
  description = "OpenSearch cluster Backend Dev password"&lt;br&gt;
  type = "SecureString"&lt;br&gt;
  value = random_password.os_kraken_password[each.key].result&lt;br&gt;
  overwrite = true&lt;br&gt;
  tier = "Standard"&lt;/p&gt;

&lt;p&gt;lifecycle {&lt;br&gt;
    ignore_changes = [value] # to prevent diff every time password is regenerated&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;h1&gt;
  
  
  Create a user
&lt;/h1&gt;

&lt;p&gt;resource "opensearch_user" "os_kraken_user" {&lt;br&gt;
  for_each = toset(var.app_environments)&lt;/p&gt;

&lt;p&gt;username = "os_kraken_${each.key}"&lt;br&gt;
  password = random_password.os_kraken_password[each.key].result&lt;br&gt;
  description = "Backend EKS ${each.key} user"&lt;/p&gt;

&lt;p&gt;depends_on = [module.opensearch]&lt;br&gt;
}&lt;/p&gt;

&lt;h1&gt;
  
  
  And a full user, role and role mapping example:
&lt;/h1&gt;

&lt;p&gt;resource "opensearch_role" "os_kraken_role" {&lt;br&gt;
  for_each = toset(var.app_environments)&lt;/p&gt;

&lt;p&gt;role_name = "os_kraken_${each.key}_role"&lt;br&gt;
  description = "Backend EKS ${each.key} role"&lt;/p&gt;

&lt;p&gt;cluster_permissions = [&lt;br&gt;
    "indices:data/read/msearch",&lt;br&gt;
    "indices:data/write/bulk*",&lt;br&gt;
   "indices:data/read/mget*"&lt;br&gt;
  ]&lt;br&gt;
  index_permissions {&lt;br&gt;
    index_patterns = ["kraken-kb-index-${each.key}"]&lt;br&gt;
    allowed_actions = ["*"]&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;depends_on = [module.opensearch]&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;cluster_permissions&lt;/code&gt;, we add permissions that are required for both the index level and the cluster level, because Bedrock did not work without them, see &lt;a href="https://docs.opensearch.org/latest/security/access-control/permissions/#cluster-wide-index-permissions" rel="noopener noreferrer"&gt;Cluster wide index permissions&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Deploy and check in Dashboards:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffavv379fckl9srhrhrho.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffavv379fckl9srhrhrho.png" width="525" height="439"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Adding IAM Users
&lt;/h3&gt;

&lt;p&gt;The idea here is the same, except that instead of regular users with a login:password for authentication, IAM and its Users &amp;amp;&amp;amp; Roles are used.&lt;/p&gt;

&lt;p&gt;More on the role for Bedrock later, but for now, let’s add user mapping.&lt;/p&gt;

&lt;p&gt;What we need to do is take a list of our Backend team users, give them an IAM Policy with access to OpenSearch, and then add mapping to a local role in the OpenSearch internal users database.&lt;/p&gt;

&lt;p&gt;For now, we can use the local role &lt;code&gt;all_access&lt;/code&gt;, although it would be better to write our own later. See &lt;a href="https://docs.opensearch.org/latest/security/access-control/users-roles/#predefined-roles" rel="noopener noreferrer"&gt;Predefined roles&lt;/a&gt; and &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/fgac.html#fgac-master-user" rel="noopener noreferrer"&gt;About the master user&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Add a new variable to the &lt;code&gt;variables.tf&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
...&lt;/p&gt;

&lt;p&gt;variable "backend_team_users_arns" {&lt;br&gt;
  type = list(string)&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Its value in the &lt;code&gt;prod.tfvars&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
...&lt;/p&gt;

&lt;p&gt;backend_team_users_arns = [&lt;br&gt;
  "arn:aws:iam::492*&lt;strong&gt;148:user/arseny",&lt;br&gt;
  "arn:aws:iam::492&lt;/strong&gt;&lt;em&gt;148:user/misha",&lt;br&gt;
  "arn:aws:iam::492&lt;/em&gt;&lt;strong&gt;148:user/oleksii",&lt;br&gt;
  "arn:aws:iam::492&lt;/strong&gt;*148:user/vladimir",&lt;br&gt;
  "os_root"&lt;br&gt;
]&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Here, we had to mess around with the user &lt;code&gt;os_root&lt;/code&gt;, because otherwise it would be removed from the role.&lt;/p&gt;

&lt;p&gt;So, it’s better to make normal roles — but for MVP, it’s okay.&lt;/p&gt;

&lt;p&gt;And we add the mapping of these IAM Users to the role &lt;code&gt;all_access&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
...&lt;/p&gt;

&lt;h3&gt;
  
  
  BACKEND TEAM
&lt;/h3&gt;

&lt;p&gt;resource "opensearch_roles_mapping" "all_access_mapping" {&lt;br&gt;
  role_name = "all_access"&lt;/p&gt;

&lt;p&gt;users = var.backend_team_users_arns&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Deploy, check the &lt;code&gt;all_access&lt;/code&gt; role:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foo93gtrfzg4tqnuflzov.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foo93gtrfzg4tqnuflzov.png" width="800" height="277"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Note&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;: ChatGPT stubbornly insisted on adding IAM Users to Backend Roles, but no, and this is clearly stated in the documentation — you need to add them to Users, see&lt;/em&gt; &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/fgac.html#fgac-more-masters" rel="noopener noreferrer"&gt;&lt;em&gt;Additional master users&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And for all the IAM Users we need to add an IAM policy with access.&lt;/p&gt;

&lt;p&gt;Again, for MVP, we can simply take the AWS managed policy &lt;a href="https://docs.aws.amazon.com/aws-managed-policy/latest/reference/AmazonOpenSearchServiceFullAccess.html" rel="noopener noreferrer"&gt;&lt;code&gt;AmazonOpenSearchServiceFullAccess&lt;/code&gt;&lt;/a&gt;, which is connected to the IAM Group:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F396ey0maakyzb45dbbt1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F396ey0maakyzb45dbbt1.png" width="800" height="744"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating AWS Bedrock IAM Roles and OpenSearch Role mappings
&lt;/h3&gt;

&lt;p&gt;We already have Bedrock, now just need to create new IAM Roles and map them to OpenSearch Roles.&lt;/p&gt;

&lt;p&gt;Add the &lt;code&gt;iam.tf&lt;/code&gt; file - describe the IAM Role and IAM Policy (Identity-based Policy for access to OpenSearch), also in a loop for each of the &lt;code&gt;var.app_environments&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;h3&gt;
  
  
  MAIN ROLE FOR KNOWLEDGE BASE
&lt;/h3&gt;

&lt;h1&gt;
  
  
  grants permissions for AWS Bedrock to interact with other AWS services
&lt;/h1&gt;

&lt;p&gt;resource "aws_iam_role" "knowledge_base_role" {&lt;br&gt;
  for_each = toset(var.app_environments)&lt;br&gt;
  name = "${var.project_name}-role-${each.key}-managed"&lt;br&gt;
  assume_role_policy = jsonencode({&lt;br&gt;
    Version = "2012-10-17"&lt;br&gt;
    Statement = [&lt;br&gt;
      {&lt;br&gt;
        Action = "sts:AssumeRole"&lt;br&gt;
        Effect = "Allow"&lt;br&gt;
        Principal = {&lt;br&gt;
          Service = "bedrock.amazonaws.com"&lt;br&gt;
        }&lt;br&gt;
        Condition = {&lt;br&gt;
          StringEquals = {&lt;br&gt;
            "aws:SourceAccount" = data.aws_caller_identity.current.account_id&lt;br&gt;
          }&lt;br&gt;
          ArnLike = {&lt;br&gt;
            # restricts the role to be assumed only by Bedrock knowledge base in the specified region&lt;br&gt;
            "aws:SourceArn" = "arn:aws:bedrock:${var.aws_region}:${data.aws_caller_identity.current.account_id}:knowledge-base/*"&lt;br&gt;
          }&lt;br&gt;
        }&lt;br&gt;
      }&lt;br&gt;
    ]&lt;br&gt;
  })&lt;br&gt;
}&lt;/p&gt;

&lt;h1&gt;
  
  
  IAM policy for Knowledge Base to access OpenSearch Managed
&lt;/h1&gt;

&lt;p&gt;resource "aws_iam_policy" "knowledge_base_opensearch_policy" {&lt;br&gt;
  for_each = toset(var.app_environments)&lt;br&gt;
  name = "${var.project_name}-kb-opensearch-policy-${each.key}-managed"&lt;br&gt;
  policy = jsonencode({&lt;br&gt;
    Version = "2012-10-17"&lt;br&gt;
    Statement = [&lt;br&gt;
      {&lt;br&gt;
        Effect = "Allow"&lt;br&gt;
        Action = [&lt;br&gt;
          "es:&lt;em&gt;",&lt;br&gt;
        ]&lt;br&gt;
        Resource = [&lt;br&gt;
          module.opensearch.domain_arn,&lt;br&gt;
          "${module.opensearch.domain_arn}/&lt;/em&gt;"&lt;br&gt;
        ]&lt;br&gt;
      }&lt;br&gt;
    ]&lt;br&gt;
  })&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;resource "aws_iam_role_policy_attachment" "knowledge_base_opensearch" {&lt;br&gt;
  for_each = toset(var.app_environments)&lt;br&gt;
  role = aws_iam_role.knowledge_base_role[each.key].name&lt;br&gt;
  policy_arn = aws_iam_policy.knowledge_base_opensearch_policy[each.key].arn&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Next, in the &lt;code&gt;opensearch_users.tf&lt;/code&gt;, let's create:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;opensearch_role&lt;/code&gt;: with &lt;code&gt;cluster_permissions&lt;/code&gt; and &lt;code&gt;index_permissions&lt;/code&gt; for each index&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;locals&lt;/code&gt; with all the IAM Roles we created above&lt;/li&gt;
&lt;li&gt;and &lt;code&gt;opensearch_roles_mapping&lt;/code&gt; for each &lt;code&gt;opensearch_role.os_bedrock_roles&lt;/code&gt;, which we add to each &lt;code&gt;opensearch_rolevia backend_roles&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It looks something like this:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
...&lt;/p&gt;

&lt;h4&gt;
  
  
  BEDROCK
&lt;/h4&gt;

&lt;p&gt;resource "opensearch_role" "os_bedrock_roles" {&lt;br&gt;
  for_each = toset(var.app_environments)&lt;br&gt;
  role_name = "os_bedrock_${each.key}_role"&lt;br&gt;
  description = "Backend Bedrock KB ${each.key} role"&lt;/p&gt;

&lt;p&gt;cluster_permissions = [&lt;br&gt;
    "indices:data/read/msearch",&lt;br&gt;
    "indices:data/write/bulk*",&lt;br&gt;
    "indices:data/read/mget*"&lt;br&gt;
    ]&lt;/p&gt;

&lt;p&gt;index_permissions {&lt;br&gt;
    index_patterns = ["kraken-kb-index-${each.key}"]&lt;br&gt;
    allowed_actions = ["*"]&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;depends_on = [module.opensearch]&lt;br&gt;
}&lt;/p&gt;

&lt;h1&gt;
  
  
  'aws_iam_role' is defined in iam.tf
&lt;/h1&gt;

&lt;p&gt;locals {&lt;br&gt;
  knowledge_base_role_arns = {&lt;br&gt;
    for env, role in aws_iam_role.knowledge_base_role :&lt;br&gt;
    env =&amp;gt; role.arn&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;resource "opensearch_roles_mapping" "os_bedrock_role_mappings" {&lt;br&gt;
  for_each = toset(var.app_environments)&lt;br&gt;
  role_name = opensearch_role.os_bedrock_roles[each.key].role_name&lt;/p&gt;

&lt;p&gt;backend_roles = [&lt;br&gt;
    local.knowledge_base_role_arns[each.key]&lt;br&gt;
  ]&lt;/p&gt;

&lt;p&gt;depends_on = [module.opensearch]&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Actually, this is where I encountered Bedrock access errors, which forced me to add &lt;code&gt;cluster_permissions&lt;/code&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;The knowledge base storage configuration provided is invalid… Request failed: [security_exception] no permissions for [indices:data/read/msearch] and User [name=arn:aws:iam::492*&lt;strong&gt;148:role/kraken-kb-role-dev, backend_roles=[arn:aws:iam::492&lt;/strong&gt;*148:role/kraken-kb-role-dev], requestedTenant=null]&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Deploy, check:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsn4op9v348u81ex5906s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsn4op9v348u81ex5906s.png" width="800" height="298"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating OpenSearch indexes
&lt;/h3&gt;

&lt;p&gt;The provider already exists, so we’ll take the  &lt;a href="https://registry.terraform.io/providers/opensearch-project/opensearch/latest/docs/resources/index" rel="noopener noreferrer"&gt;&lt;code&gt;opensearch_index&lt;/code&gt;&lt;/a&gt; resource.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;locals&lt;/code&gt;, we write the index template - I just took it from the developers from the old configuration:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
locals {&lt;br&gt;
  # 'atlas-kb-prod'&lt;br&gt;
  env_name = "${var.project_name}-${var.environment}"&lt;br&gt;
  # 'opensearch.prod.example.co'&lt;br&gt;
  os_custom_domain_name = "opensearch.${var.dns_zone}"&lt;/p&gt;

&lt;p&gt;# index mappings&lt;/p&gt;

&lt;p&gt;os_index_mappings = &amp;lt;&amp;lt;-EOF&lt;br&gt;
    {&lt;br&gt;
      "dynamic_templates": [&lt;br&gt;
        {&lt;br&gt;
          "strings": {&lt;br&gt;
            "match_mapping_type": "string",&lt;br&gt;
            "mapping": {&lt;br&gt;
              "fields": {&lt;br&gt;
                "keyword": {&lt;br&gt;
                  "ignore_above": 8192,&lt;br&gt;
                  "type": "keyword"&lt;br&gt;
                }&lt;br&gt;
              },&lt;br&gt;
              "type": "text"&lt;br&gt;
            }&lt;br&gt;
          }&lt;br&gt;
        }&lt;br&gt;
      ],&lt;br&gt;
      "properties": {&lt;br&gt;
        "bedrock-knowledge-base-default-vector": {&lt;br&gt;
          "type": "knn_vector",&lt;br&gt;
          "dimension": 1024,&lt;br&gt;
          "method": {&lt;br&gt;
            "name": "hnsw",&lt;br&gt;
            "engine": "faiss",&lt;br&gt;
            "parameters": {&lt;br&gt;
              "m": 16,&lt;br&gt;
              "ef_construction": 512&lt;br&gt;
            },&lt;br&gt;
            "space_type": "l2"&lt;br&gt;
          }&lt;br&gt;
        },&lt;br&gt;
        "AMAZON_BEDROCK_METADATA": {&lt;br&gt;
          "type": "text",&lt;br&gt;
          "index": false&lt;br&gt;
        },&lt;br&gt;
        "AMAZON_BEDROCK_TEXT_CHUNK": {&lt;br&gt;
          "type": "text",&lt;br&gt;
          "index": true&lt;br&gt;
        }&lt;br&gt;
      }&lt;br&gt;
    }&lt;br&gt;
EOF&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Create a file named &lt;code&gt;opensearch_indexes.tf&lt;/code&gt;. Add the indexes themselves - here, I decided not to use a loop, but to create separate Dev/Staging/Prod files directly:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;h1&gt;
  
  
  Dev Index
&lt;/h1&gt;

&lt;p&gt;resource "opensearch_index" "kb_vector_index_dev" {&lt;br&gt;
  name = "kraken-kb-index-dev"&lt;/p&gt;

&lt;p&gt;# enable approximate nearest neighbor search by setting index_knn to true&lt;br&gt;
  index_knn = true&lt;br&gt;
  index_knn_algo_param_ef_search = "512"&lt;br&gt;
  number_of_shards = "1"&lt;br&gt;
  number_of_replicas = "1"&lt;br&gt;
  mappings = local.os_index_mappings&lt;/p&gt;

&lt;p&gt;# When new documents are ingested into the Knowledge Base,&lt;br&gt;
  # OpenSearch automatically creates field mappings for new metadata fields under&lt;br&gt;
  # AMAZON_BEDROCK_METADATA. Since these fields are created outside of TF resource definitions,&lt;br&gt;
  # TF detects them as configuration drift and attempts to recreate the index to match its&lt;br&gt;
  # known state.&lt;br&gt;
  #&lt;br&gt;
  # This lifecycle rule prevents unnecessary index recreation by ignoring mapping changes&lt;br&gt;
  # that occur after initial deployment.&lt;br&gt;
  lifecycle {&lt;br&gt;
    ignore_changes = [mappings]&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;...&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Deploy, check:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvwobi88yyk8u53m8ixe7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvwobi88yyk8u53m8ixe7.png" width="800" height="378"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That’s basically it.&lt;/p&gt;

&lt;p&gt;Bedrock is already connected, everything is working.&lt;/p&gt;

&lt;p&gt;But it took a little bit of effort.&lt;/p&gt;

&lt;p&gt;And I’m sure it won’t be the last time :-)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;/em&gt; &lt;a href="https://rtfm.co.ua/en/terraform-creating-an-aws-opensearch-service-cluster-and-users/" rel="noopener noreferrer"&gt;&lt;em&gt;RTFM: Linux, DevOps, and system administration&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>devops</category>
      <category>aws</category>
      <category>terraform</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Terraform: using Ephemeral Resources and Write-Only Attributes</title>
      <dc:creator>Arseny Zinchenko</dc:creator>
      <pubDate>Mon, 29 Dec 2025 10:00:00 +0000</pubDate>
      <link>https://dev.to/setevoy/terraform-using-ephemeral-resources-and-write-only-attributes-56lg</link>
      <guid>https://dev.to/setevoy/terraform-using-ephemeral-resources-and-write-only-attributes-56lg</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frdcgbe0zxi1aeku3sf31.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frdcgbe0zxi1aeku3sf31.png" width="480" height="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ephemeral resources and write-only arguments appeared in Terraform a long time ago, back in version 1.10, but there was no opportunity to write about them in detail.&lt;/p&gt;

&lt;p&gt;The main idea behind them is not to leave “traces” in the state file, which is especially useful for passwords or tokens, because the data only exists during the execution of Terraform itself in its memory.&lt;/p&gt;

&lt;p&gt;However, there are certain limitations to their use — we’ll look at those later, but first, let’s see everything in action.&lt;/p&gt;

&lt;h3&gt;
  
  
  Contents
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Example without ephemeral values and write-only arguments&lt;/li&gt;
&lt;li&gt;Using Write-Only Attributes&lt;/li&gt;
&lt;li&gt;Using Ephemeral Resources&lt;/li&gt;
&lt;li&gt;The “This output value is not declared as returning an ephemeral value” error&lt;/li&gt;
&lt;li&gt;The “Ephemeral outputs are not allowed in context of a root module” error&lt;/li&gt;
&lt;li&gt;Using values from Ephemeral resources&lt;/li&gt;
&lt;li&gt;Using Ephemeral Outputs&lt;/li&gt;
&lt;li&gt;Useful links&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Example without ephemeral values and write-only arguments
&lt;/h3&gt;

&lt;p&gt;Let’s start with the old scheme, without using ephemeral resources and write-only arguments  —  we will create a random password, the resource &lt;code&gt;aws_secretsmanager_secret&lt;/code&gt;, store this password in it, and get it from data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;provider "aws" {
  region = "us-east-1"
  default_tags {
    tags = {
      component = "devops"
      created-by = "terraform"
      environment = "test"
    }
  }
}

### RESOURCES ###

# generate a random password
resource "random_password" "test_random_password" {
   length = 8
   special = false
}

# create an AWS Secret resource
resource "aws_secretsmanager_secret" "test_aws_secret" {
  name = "db_password"
  description = "database passsword"
  recovery_window_in_days = 0
}

# create an AWS Secret value
resource "aws_secretsmanager_secret_version" "test_aws_secret_version" {
  secret_id = aws_secretsmanager_secret.test_aws_secret.id
  secret_string = random_password.test_random_password.result
}

### DATA SOURCES ###

# retrieve the AWS Secret value
data "aws_secretsmanager_secret_version" "test_aws_secret_data" {
  secret_id = aws_secretsmanager_secret.test_aws_secret.id

  depends_on = [aws_secretsmanager_secret_version.test_aws_secret_version]
}

### OUTPUTS ###

# get the random password value
output test_random_password {
  value = random_password.test_random_password.result
  sensitive = true
}

# get the AWS Secret value
output "test_aws_secret" {
  value = data.aws_secretsmanager_secret_version.test_aws_secret_data.secret_string
  sensitive = true
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here we are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;resource "random_password"&lt;/code&gt;: generate the password itself&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;resource "aws_secretsmanager_secret"&lt;/code&gt;: create a new entry in AWS Secrets Manager&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;resource "aws_secretsmanager_secret_version"&lt;/code&gt;: write the value from resource "random_password" to this Secret&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data “aws_secretsmanager_secret_version”&lt;/code&gt;: get the value from AWS Secrets Manager&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;output “test_random_password”&lt;/code&gt;: output the value from resource ‘random_password’&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;output “test_aws_secret”&lt;/code&gt;: output the value obtained from AWS Secrets Manager&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Execute &lt;code&gt;terraform init&lt;/code&gt; and &lt;code&gt;terraform apply&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

Outputs:

test_aws_secret = &amp;lt;sensitive&amp;gt;
test_random_password = &amp;lt;sensitive&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks OK  —  in &lt;code&gt;outputs&lt;/code&gt;, thanks to the &lt;code&gt;sensitive = true&lt;/code&gt;, nothing is displayed.&lt;/p&gt;

&lt;p&gt;But the password is in the state file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ cat terraform.tfstate
{
  ...
  "outputs": {
    "test_aws_secret": {
      "value": "1atcZYGR",
      "type": "string",
      "sensitive": true
    },
    "test_random_password": {
      "value": "1atcZYGR",
      "type": "string",
      "sensitive": true
    }
  },
...
  "resources": [
    {
      "mode": "data",
      "type": "aws_secretsmanager_secret_version",
      "name": "test_aws_secret_data",
      ...
            "secret_string": "1atcZYGR",
...
    {
      "mode": "managed",
      "type": "aws_secretsmanager_secret_version",
      "name": "test_aws_secret_version",
      ...
            "secret_string": "1atcZYGR",
...
    {
      "mode": "managed",
      "type": "random_password",
      "name": "test_random_password",
      ...
            "result": "1atcZYGR",
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now let’s start hiding this data from the state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Using Write-Only Attributes
&lt;/h3&gt;

&lt;p&gt;Resource attributes with the suffix &lt;code&gt;_wo&lt;/code&gt; are "write-only" data, meaning that Terraform keeps them in memory during operations but does not store them anywhere.&lt;/p&gt;

&lt;p&gt;However, not all resources support these attributes. For example, in AWS RDS, you can pass a password via the &lt;code&gt;password_wo&lt;/code&gt; attribute of the &lt;a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/db_instance#password_wo-1" rel="noopener noreferrer"&gt;&lt;code&gt;aws_db_instance&lt;/code&gt;&lt;/a&gt; resource, but in &lt;code&gt;aws_opensearch_domain&lt;/code&gt; and its &lt;a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/opensearch_domain#master_user_password-1" rel="noopener noreferrer"&gt;&lt;code&gt;master_user_password&lt;/code&gt;&lt;/a&gt; attribute to create a root user in the internal user database - not yet.&lt;/p&gt;

&lt;p&gt;Official documentation — &lt;a href="https://developer.hashicorp.com/terraform/language/resources/ephemeral/write-only" rel="noopener noreferrer"&gt;Use write-only arguments&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;aws_secretsmanager_secret_version&lt;/code&gt; also supports write-only attributes - &lt;code&gt;secret_string_wo&lt;/code&gt; instead of &lt;code&gt;secret_string&lt;/code&gt;, and &lt;code&gt;secret_string_wo_version&lt;/code&gt; instead of &lt;code&gt;secret_string_version&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The use of &lt;code&gt;secret_string_wo_version&lt;/code&gt; is mandatory for &lt;code&gt;secret_string_wo&lt;/code&gt;, because since Terraform does not store password information, it will not know when to update it. To do this, we set a version that we increment each time we want to update the password.&lt;/p&gt;

&lt;p&gt;Edit the code, change the only &lt;code&gt;resource “aws_secretsmanager_secret_version”&lt;/code&gt; - set &lt;code&gt;secret_string_wo&lt;/code&gt; and &lt;code&gt;secret_string_wo_version&lt;/code&gt;, leaving the rest unchanged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
# create an AWS Secret value
resource "aws_secretsmanager_secret_version" "test_aws_secret_version" {
  secret_id = aws_secretsmanager_secret.test_aws_secret.id
  #secret_string = random_password.test_random_password.result
  secret_string_wo = random_password.test_random_password.result
  secret_string_wo_version = 1
}
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;terraform apply&lt;/code&gt;, and check the state now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ cat terraform.tfstate
{
  ...
  "outputs": {
    "test_aws_secret": {
      "value": "1atcZYGR",
      "type": "string",
      "sensitive": true
    },
    "test_random_password": {
      "value": "1atcZYGR",
      "type": "string",
      "sensitive": true
    }
  },
...
  "resources": [
    {
      "mode": "data",
      "type": "aws_secretsmanager_secret_version",
      "name": "test_aws_secret_data",
      ...
            "secret_string": "1atcZYGR",
...
    {
      "mode": "managed",
      "type": "aws_secretsmanager_secret_version",
      "name": "test_aws_secret_version",
      ...
            "secret_string": "",
            "secret_string_wo": null,
            "secret_string_wo_version": 1,

...
    {
      "mode": "managed",
      "type": "random_password",
      "name": "test_random_password",
      ...
            "result": "1atcZYGR",
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now we have &lt;code&gt;managed.aws_secretsmanager_secret_version.test_aws_secret_version&lt;/code&gt; with no values for &lt;code&gt;secret_string&lt;/code&gt; and &lt;code&gt;secret_string_wo&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Using Ephemeral Resources
&lt;/h3&gt;

&lt;p&gt;The idea behind “ephemeral” resources is the same as with write-only arguments — these resources only exist in Terraform’s memory during the execution of &lt;code&gt;terraform apply&lt;/code&gt; and are not stored in the state file.&lt;/p&gt;

&lt;p&gt;However, the use of such resources is limited:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you can refer to them in write-only arguments&lt;/li&gt;
&lt;li&gt;in other ephemeral resources&lt;/li&gt;
&lt;li&gt;in &lt;code&gt;locals&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;in ephemeral variables&lt;/li&gt;
&lt;li&gt;in providers, provisioners, and connections&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Documentation — &lt;a href="https://developer.hashicorp.com/terraform/language/ephemeral" rel="noopener noreferrer"&gt;Ephemeral block reference&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Let’s edit our code and change &lt;code&gt;resource “random_password”&lt;/code&gt; to &lt;code&gt;ephemeral "random_password"&lt;/code&gt;, leave &lt;code&gt;resource “aws_secretsmanager_secret_version”&lt;/code&gt; - it will write the password to AWS Secrets Manager but will not store the value in state, and add a new resource - &lt;code&gt;ephemeral “aws_secretsmanager_secret_version”&lt;/code&gt;, through which we will get this password back in Terraform.&lt;/p&gt;

&lt;p&gt;At the same time, in the &lt;code&gt;secret_string_wo&lt;/code&gt; and in &lt;code&gt;output “test_random_password”&lt;/code&gt; we now refer to the password through &lt;em&gt;ephemeral&lt;/em&gt; - &lt;code&gt;ephemeral.random_password.test_random_password.result&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And in the &lt;code&gt;output “test_aws_secret”&lt;/code&gt; we also use &lt;code&gt;ephemeral.aws_secretsmanager_secret_version.test_aws_secret_data.secret_string&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;data "aws_secretsmanager_secret_version"&lt;/code&gt; can be removed, because we will now get the password from the &lt;code&gt;ephemeral “aws_secretsmanager_secret_version”&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...

### RESOURCES ###

# generate a random password
ephemeral "random_password" "test_random_password" {
   length = 8
   special = false
}

# create an AWS Secret resource
resource "aws_secretsmanager_secret" "test_aws_secret" {
  name = "db_password"
  description = "database passsword"
  recovery_window_in_days = 0
}

# create an AWS Secret value
resource "aws_secretsmanager_secret_version" "test_aws_secret_version" {
  secret_id = aws_secretsmanager_secret.test_aws_secret.id
  #secret_string = random_password.test_random_password.result
  secret_string_wo = ephemeral.random_password.test_random_password.result
  secret_string_wo_version = 1
}

### DATA SOURCES ###

# Retrieve the password from Secrets Manager (ephemeral)
ephemeral "aws_secretsmanager_secret_version" "test_aws_secret_version_ephemeral" {
  secret_id = aws_secretsmanager_secret.test_aws_secret.id
}

# retrieve the AWS Secret value
# data "aws_secretsmanager_secret_version" "test_aws_secret_data" {
# secret_id = aws_secretsmanager_secret.test_aws_secret.id

# depends_on = [aws_secretsmanager_secret_version.test_aws_secret_version]
# }

### OUTPUTS ###

# get the random password value
output test_random_password {
  value = ephemeral.random_password.test_random_password.result
  sensitive = true
}

# get the AWS Secret value
output "test_aws_secret" {
  value = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
  sensitive = true
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The “This output value is not declared as returning an ephemeral value” error
&lt;/h3&gt;

&lt;p&gt;Execute &lt;code&gt;terraform apply&lt;/code&gt; and catch the first error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
│ Error: Ephemeral value not allowed
│ 
│ on main.tf line 53, in output "test_random_password":
│ 53: value = ephemeral.random_password.test_random_password.result
│ 
│ This output value is not declared as returning an ephemeral value, so it cannot be set to a result derived from an ephemeral value.
╵
╷
│ Error: Ephemeral value not allowed
│ 
│ on main.tf line 59, in output "test_aws_secret":
│ 59: value = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
│ 
│ This output value is not declared as returning an ephemeral value, so it cannot be set to a result derived from an ephemeral value.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But even if we add the parameter &lt;code&gt;ephemeral = true&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
### OUTPUTS ###

# get the random password value
output test_random_password {
  value = ephemeral.random_password.test_random_password.result
  sensitive = true
  ephemeral = true
}

# get the AWS Secret value
output "test_aws_secret" {
  value = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
  sensitive = true
  ephemeral = true
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It still won’t work.&lt;/p&gt;

&lt;h3&gt;
  
  
  The “Ephemeral outputs are not allowed in context of a root module” error
&lt;/h3&gt;

&lt;p&gt;Now the error will look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
╷
│ Error: Ephemeral output not allowed
│ 
│ on main.tf line 52:
│ 52: output test_random_password {
│ 
│ Ephemeral outputs are not allowed in context of a root module
╵
╷
│ Error: Ephemeral output not allowed
│ 
│ on main.tf line 59:
│ 59: output "test_aws_secret" {
│ 
│ Ephemeral outputs are not allowed in context of a root module
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because Ephemeral outputs can only be used in modules — we’ll see how later.&lt;/p&gt;

&lt;p&gt;OK — for now, let’s just remove &lt;code&gt;Outputs&lt;/code&gt;, and now &lt;code&gt;terraform apply&lt;/code&gt; runs without any problems:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ terraform apply
...
random_password.test_random_password: Refreshing state... [id=none]
ephemeral.random_password.test_random_password: Opening...
ephemeral.random_password.test_random_password: Opening complete after 0s
...
ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Opening...
...
ephemeral.random_password.test_random_password: Closing...
ephemeral.random_password.test_random_password: Closing complete after 0s
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Please note that for ephemeral resources, Terraform now performs &lt;em&gt;Opening&lt;/em&gt; and &lt;em&gt;Closing&lt;/em&gt; operations instead of &lt;em&gt;Reading&lt;/em&gt; and &lt;em&gt;Refreshing state&lt;/em&gt;. That is, it simply creates an object in memory, reads the resource into it, and then “closes” and removes it from memory.&lt;/p&gt;

&lt;p&gt;Let’s check the state file now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
    {
      "mode": "managed",
      "type": "aws_secretsmanager_secret_version",
      "name": "test_aws_secret_version",
      ...
            "secret_string": "",
            "secret_string_wo": null,
            "secret_string_wo_version": 1,
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now we have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;resources &lt;code&gt;ephemeral “random_password”&lt;/code&gt; and &lt;code&gt;ephemeral “aws_secretsmanager_secret_version”&lt;/code&gt; are not in the state at all,&lt;/li&gt;
&lt;li&gt;and &lt;code&gt;managed.aws_secretsmanager_secret_version.test_aws_secret_version&lt;/code&gt; still has an empty field in &lt;code&gt;secret_string_wo&lt;/code&gt; because we made it write-only earlier&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;OK, but how do we use the password now? Because we removed &lt;code&gt;data “aws_secretsmanager_secret_version”&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Using values from Ephemeral resources
&lt;/h3&gt;

&lt;p&gt;We have already seen an example of referencing Ephemeral resources above when we did &lt;code&gt;secret_string_wo = ephemeral.random_password.test_random_password.result&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Similarly, we can use &lt;code&gt;ephemeral.aws_secretsmanager_secret_version.db_password_wo_ephemeral.secret_string&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;As mentioned above, we cannot do this everywhere, but it is allowed in &lt;code&gt;providers&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;To verify this, let’s run PostgreSQL with our password (we’ll take it directly from AWS Console &amp;gt; AWS Secrets Manager):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxncpjtuiw9b4e3ryo4en.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxncpjtuiw9b4e3ryo4en.png" width="676" height="670"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Launch a container, to which we pass the variable &lt;code&gt;POSTGRES_PASSWORD="1atcZYGR"&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ docker run --rm --name some-postgres -e POSTGRES_PASSWORD="1atcZYGR" -p 5432:5432 postgres
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the provider to our code and use it to connect to the container, where we will create a test database.&lt;/p&gt;

&lt;p&gt;In the provider’s &lt;code&gt;password&lt;/code&gt; field we will use a value from the &lt;code&gt;ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...

### PostgreSQL Configuration

terraform {
  required_providers {
    postgresql = {
      source = "cyrilgdn/postgresql"
      version = "~&amp;gt; 1.20"
    }
  }
}

provider "postgresql" {
  host = "localhost"
  port = 5432
  username = "postgres"
  password = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
  sslmode = "disable"
}

resource "postgresql_database" "demo_db" {
  name = "demo_db"
  template = "template0"
  connection_limit = -1
  allow_connections = true
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;terraform init&lt;/code&gt; and &lt;code&gt;terraform apply&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ terraform init &amp;amp;&amp;amp; terraform apply
...
ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Opening...
ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Opening complete after 1s
postgresql_database.demo_db: Creating...
postgresql_database.demo_db: Creation complete after 0s [id=demo_db]
ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Closing...
ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Closing complete after 0s

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ export PGPASSWORD="1atcZYGR"
$ psql -h localhost -U postgres -c "\l"
                                                    List of databases
   Name | Owner | Encoding | Locale Provider | Collate | Ctype | Locale | ICU Rules | Access privileges   
-----------+----------+----------+-----------------+------------+------------+--------+-----------+-----------------------
 demo_db | postgres | UTF8 | libc | en_US.utf8 | en_US.utf8 | | | 
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the same way, we could use an ephemeral resource via locals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
locals {
  db_password_local = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
}

provider "postgresql" {
  host = "localhost"
  port = 5432
  username = "postgres"
  password = local.db_password_local
  #password = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
  sslmode = "disable"
}

resource "postgresql_database" "demo_db" {
  name = "demo_db_via_local"
  template = "template0"
  connection_limit = -1
  allow_connections = true
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ terraform apply
...
  # postgresql_database.demo_db will be updated in-place
  ~ resource "postgresql_database" "demo_db" {
        id = "demo_db"
      ~ name = "demo_db" -&amp;gt; "demo_db_via_local"
        # (10 unchanged attributes hidden)
    }
...
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in the state file, the password is not visible anywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ cat terraform.tfstate | grep 1atcZYGR | echo $?
127
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Using Ephemeral Outputs
&lt;/h3&gt;

&lt;p&gt;Above, we tried to use &lt;code&gt;output “test_aws_secret”&lt;/code&gt; with &lt;code&gt;ephemeral = true&lt;/code&gt;, but got the error "&lt;strong&gt;&lt;em&gt;Ephemeral outputs are not allowed in context of a root module"&lt;/em&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Let’s try using it in our own module.&lt;/p&gt;

&lt;p&gt;Documentation — &lt;a href="https://developer.hashicorp.com/terraform/language/values/outputs#ephemeral-avoid-storing-values-in-state-or-plan-files" rel="noopener noreferrer"&gt;ephemeral — Avoid storing values in state or plan files&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Let’s create a module &lt;code&gt;modules/secret_ephemeral&lt;/code&gt;, in which we will generate a password and save it in AWS Secrets Manager, and add Ephemeral Output.&lt;/p&gt;

&lt;p&gt;And in the root module, we will use &lt;code&gt;outputs&lt;/code&gt; of this module to get &lt;code&gt;ephemeral “aws_secretsmanager_secret_version”&lt;/code&gt;, as we did above.&lt;/p&gt;

&lt;p&gt;Let’s write the file &lt;code&gt;modules/secret_ephemeral/secret.tf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;### RESOURCES ###

# generate a random password
ephemeral "random_password" "test_random_password" {
   length = 8
   special = false
}

# create an AWS Secret resource
resource "aws_secretsmanager_secret" "test_aws_secret" {
  name = "db_password_via_module"
  description = "database passsword"
  recovery_window_in_days = 0
}

# create an AWS Secret value
resource "aws_secretsmanager_secret_version" "test_aws_secret_version" {
  secret_id = aws_secretsmanager_secret.test_aws_secret.id
  #secret_string = random_password.test_random_password.result
  secret_string_wo = ephemeral.random_password.test_random_password.result
  secret_string_wo_version = 1
}

# Retrieve the password from Secrets Manager (ephemeral)
ephemeral "aws_secretsmanager_secret_version" "test_aws_secret_version_ephemeral" {
  secret_id = aws_secretsmanager_secret.test_aws_secret.id
}

output "password_ephemeral" {
  value = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
  ephemeral = true
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the main file &lt;code&gt;main.tf&lt;/code&gt;, remove everything related to the password, add a module call, and in &lt;code&gt;locals&lt;/code&gt; use its &lt;code&gt;output&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...

### PostgreSQL Configuration

terraform {
  required_providers {
    postgresql = {
      source = "cyrilgdn/postgresql"
      version = "~&amp;gt; 1.20"
    }
  }
}

module "secret_ephemeral" {
  source = "./modules/secret_ephemeral"
}

locals {
  db_password_local = module.secret_ephemeral.password_ephemeral
}

provider "postgresql" {
  host = "localhost"
  port = 5432
  username = "postgres"
  password = local.db_password_local
  #password = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
  sslmode = "disable"
}

resource "postgresql_database" "demo_db" {
  name = "demo_db_via"
  template = "template0"
  connection_limit = -1
  allow_connections = true
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First, you need to create a password  —  run &lt;code&gt;terraform apply&lt;/code&gt; without &lt;code&gt;resource “postgresql_database”&lt;/code&gt;, and update the container launch with the new password:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ docker run --rm --name some-postgres -e POSTGRES_PASSWORD="PHsfzcIx" -p 5432:5432 postgres
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now our provider uses a password from the Ephemeral Output &lt;code&gt;module modules/secret_ephemeral&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
module.secret_ephemeral.ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Opening...
module.secret_ephemeral.ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Opening complete after 1s
postgresql_database.demo_db: Creating...
postgresql_database.demo_db: Creation complete after 0s [id=demo_db_via]
module.secret_ephemeral.ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Closing...
module.secret_ephemeral.ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Closing complete after 0s

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the state, we still don’t have a password:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ cat terraform.tfstate | grep PHsfzcIx | echo $?
127
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That’s basically it.&lt;/p&gt;

&lt;p&gt;It’s a shame that &lt;code&gt;aws_opensearch_domain&lt;/code&gt; doesn't support write-only. I wanted to use it for the root password :-(&lt;/p&gt;

&lt;p&gt;But there is already an issue on GitHub &lt;a href="https://github.com/hashicorp/terraform-provider-aws/issues/43509" rel="noopener noreferrer"&gt;Support ephemeral “write-only” argument for aws_opensearch_domain&lt;/a&gt;, and even a comment saying “&lt;em&gt;I have started working on this issue, and will submit a PR shortly”&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;And &lt;a href="https://github.com/hashicorp/terraform-provider-aws/pull/43621/files#diff-68e0a3f9a3d665361b3e6ddaa494ffb5164cbc9ec97e2b9b14350a2d7e6e7e47" rel="noopener noreferrer"&gt;in the pull request itself&lt;/a&gt;, you can even see how it’s implemented.&lt;/p&gt;

&lt;h3&gt;
  
  
  Useful links
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://schimizu.com/securely-storing-credentials-in-terraform-with-ephemeral-blocks-and-write-only-attributes-6867826b9ef7" rel="noopener noreferrer"&gt;Securely storing credentials in Terraform with ‘Ephemeral Blocks’ and ‘Write-Only’ attributes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://scalr.com/learning-center/understanding-ephemerality-in-terraform/" rel="noopener noreferrer"&gt;Understanding ephemerality in Terraform&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://thomasthornton.cloud/2025/04/24/ensuring-terraform-state-security-with-ephemeral-values-and-write-only-outputs/" rel="noopener noreferrer"&gt;Ensuring Terraform State Security with Ephemeral Values and Write-Only Outputs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/@amareswer/terraform-1-10-secure-secrets-with-ephemeral-values-a5041800ff58" rel="noopener noreferrer"&gt;Terraform 1.10: Secure Secrets with Ephemeral Values&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;/em&gt; &lt;a href="https://rtfm.co.ua/en/terraform-using-ephemeral-resources-and-write-only-attributes/" rel="noopener noreferrer"&gt;&lt;em&gt;RTFM: Linux, DevOps, and system administration&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>terraform</category>
      <category>devops</category>
      <category>tutorial</category>
      <category>todayilearned</category>
    </item>
    <item>
      <title>Golang: interfaces - the “magic” of calling methods through interface</title>
      <dc:creator>Arseny Zinchenko</dc:creator>
      <pubDate>Thu, 04 Dec 2025 09:25:13 +0000</pubDate>
      <link>https://dev.to/setevoy/golang-interfaces-the-magic-of-calling-methods-through-interface-2p7l</link>
      <guid>https://dev.to/setevoy/golang-interfaces-the-magic-of-calling-methods-through-interface-2p7l</guid>
      <description>&lt;h3&gt;
  
  
  Golang: interfaces - the “magic” of calling methods through interface
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fawyqu9d1w3koa1tonyms.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fawyqu9d1w3koa1tonyms.jpeg" width="799" height="351"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Interfaces in Go allow you to describe access to data or behavior without providing concrete implementations inside the interface itself.&lt;/p&gt;

&lt;p&gt;In this way, we create a “common bus” that we can then use to “connect” external “systems.”&lt;/p&gt;

&lt;p&gt;In other words, an interface is an abstraction that defines a &lt;em&gt;contract&lt;/em&gt;. The contract describes what can be done, but the concrete type decides how it is done.&lt;/p&gt;

&lt;p&gt;Originally, I planned to write a post about using interfaces in practice, but it naturally evolved into a more in-depth look at how they are implemented internally and what actually happens during a method call through an interface.&lt;/p&gt;

&lt;p&gt;It would probably make sense to start with pointers and methods in Go, since this topic heavily relies on them, but that is material for another post.&lt;/p&gt;

&lt;p&gt;See also the following related article &lt;a href="https://rtfm.co.ua/golang-interfejsi-tipi-ta-metodi-na-prikladi-io-copy/" rel="noopener noreferrer"&gt;Golang: interfaces, types, and methods using the example of io.Copy()&lt;/a&gt; (&lt;em&gt;TODO: add translation, in Ukrainian for now&lt;/em&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Contents
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-interfaces-the-magic-of-calling-methods-through-interface/#Empty_interfaces" rel="noopener noreferrer"&gt;Empty interfaces&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-interfaces-the-magic-of-calling-methods-through-interface/#Interfaces_and_methods" rel="noopener noreferrer"&gt;Interfaces and methods&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-interfaces-the-magic-of-calling-methods-through-interface/#The_interface_%E2%80%9Cmagic%E2%80%9D_the_iface_structure" rel="noopener noreferrer"&gt;The interface “magic”: the iface structure&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-interfaces-the-magic-of-calling-methods-through-interface/#Useful_links" rel="noopener noreferrer"&gt;Useful links&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Empty interfaces
&lt;/h3&gt;

&lt;p&gt;The simplest form of an interface is one that does not specify any methods.&lt;/p&gt;

&lt;p&gt;Using such an interface, we can create a function that accepts a value of any type. Otherwise, we would have to explicitly specify the parameter type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;package main

import "fmt"

// define an empty interface
// it can hold a value of any type
type Any interface{}

func printValue(v Any) {
  // print the value
  fmt.Println("Value:", v)
}

func main() {
  // pass int
  printValue(42)
  // pass string
  printValue("hello")
  // pass float
  printValue(3.14)
  // pass slice
  printValue([]int{1, 2, 3})
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without an interface, we would have to create separate functions for each type or use generics instead.&lt;/p&gt;

&lt;p&gt;Another option is to use the &lt;code&gt;any&lt;/code&gt; type, which is just an alias for &lt;code&gt;interface{}&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
type MyAny any

func printValue(v Any) {
  // print the value
  fmt.Println("Value:", v)
}
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Interfaces and methods
&lt;/h3&gt;

&lt;p&gt;If the empty interface &lt;code&gt;any&lt;/code&gt; says "I accept any value", then a classic interface says "I am only interested in specific behavior".&lt;/p&gt;

&lt;p&gt;This behavior is described via a set of method signatures that a type must implement in order to satisfy the interface.&lt;/p&gt;

&lt;p&gt;When a value is assigned to an interface variable, or passed to a function that expects an interface, Go checks whether the underlying type implements all required methods.&lt;/p&gt;

&lt;p&gt;From that point on, we can call the methods through the interface.&lt;/p&gt;

&lt;p&gt;In other words, an interface acts as an intermediary that allows you to call a method regardless of the concrete type.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;package main

import "fmt"

// define an interface 'MyInterface' with a single method 'MyMethod' returning a string
type MyInterface interface {
  MyMethod() string
}

// define 'MyStruct' struct with a 'MyField' field
type MyStruct struct {
  MyField string
}

// define 'MyMethod' method for the 'MyStruct' struct
// this makes 'MyStruct' implicitly implement 'MyInterface'
// 'MyMethod' method uses 'MyStruct' as the receiver, so this method is tied to the 'MyStruct' type
func (receiver MyStruct) MyMethod() string {
  return "Executing " + receiver.MyField
}

// define a function 'sayHello()' which accepts any type that implements 'MyInterface'
// and prints the value returned by its 'MyMethod'
func sayHello(g MyInterface) {
  fmt.Println(g.MyMethod())
}

func main() {
  // create an instance of MyStruct
  myObj := MyStruct{MyField: "Hello, Interface!"}

  // pass the MyStruct instance to the function.
  // this works because MyStruct implements MyInterface.
  sayHello(myObj)
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;declare our own interface type with the name &lt;code&gt;MyInterface&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;this interface describes one method signature - &lt;code&gt;MyMethod()&lt;/code&gt;, and this method should return data of type string&lt;/li&gt;
&lt;li&gt;create your own data type &lt;code&gt;MyStruct&lt;/code&gt; with type &lt;code&gt;struct&lt;/code&gt;, which has one field &lt;code&gt;MyField&lt;/code&gt; with type &lt;code&gt;string&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;“bind” the &lt;code&gt;MyMethod()&lt;/code&gt; function to the struct by specifying a receiver (&lt;code&gt;receiver MyStruct&lt;/code&gt;), which makes &lt;code&gt;MyStruct&lt;/code&gt; implement &lt;code&gt;MyInterface&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;describe our “main working” function &lt;code&gt;sayHello()&lt;/code&gt;, which takes the interface as an argument and calls the method &lt;code&gt;MyMethod()&lt;/code&gt;, which is in this interface&lt;/li&gt;
&lt;li&gt;create an instance of our data type &lt;code&gt;MyStruct&lt;/code&gt;, in which we write the value "&lt;em&gt;Hello, Interface!&lt;/em&gt;" in the field &lt;code&gt;MyField&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;and call our working function, passing this structure as an argument&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I tried to make the relationships between all the parts more explicit, since they are not always obvious:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffrmpg61cjcy4h7c7x7kt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffrmpg61cjcy4h7c7x7kt.png" width="800" height="638"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;create an object &lt;code&gt;myObj&lt;/code&gt; with type &lt;code&gt;MyStruct&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;call &lt;code&gt;sayHello()&lt;/code&gt;, passing the argument &lt;code&gt;myObj&lt;/code&gt;, which inside the function &lt;code&gt;sayHello ()&lt;/code&gt; becomes the variable &lt;code&gt;g&lt;/code&gt;, which is associated with our interface &lt;code&gt;MyInterface&lt;/code&gt;, which provides access to the method &lt;code&gt;MyMethod()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;in the &lt;code&gt;sayHello()&lt;/code&gt; function, by calling &lt;code&gt;g.MyMethod()&lt;/code&gt;, we refer to the &lt;code&gt;MyInterface&lt;/code&gt; interface, saying "&lt;em&gt;I need your MyMethod() method&lt;/em&gt;"&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;MyInterface&lt;/code&gt; interface "sees" that a &lt;code&gt;MyStruct&lt;/code&gt; value is currently stored inside it, and redirects the call to that concrete implementation&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Okay, now the picture is becoming clearer.&lt;/p&gt;

&lt;p&gt;Except for one thing - how does the interface “see” that it contains the object &lt;code&gt;myObj&lt;/code&gt; with the method &lt;code&gt;MyMethod()&lt;/code&gt;?&lt;/p&gt;

&lt;h3&gt;
  
  
  The interface “magic”: the &lt;code&gt;iface&lt;/code&gt; structure
&lt;/h3&gt;

&lt;p&gt;To understand this, let’s look at how Go represents an interface in memory and recreate a simplified version of that structure.&lt;/p&gt;

&lt;p&gt;Then, using pointers, we can inspect the actual memory addresses and stored values.&lt;/p&gt;

&lt;p&gt;Let’s “slightly” modify the original code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;package main

import (
  "fmt"
  "unsafe"
)

// define MyInterface interface
// (same as before)
type MyInterface interface {
  MyMethod() string
}

// define MyStruct struct
// (same as before)
type MyStruct struct {
  MyField string
}

// define MyMethod with a POINTER receiver
// - before: func (p MyStruct) MyMethod() ... // by value
// - now: func (p *MyStruct) MyMethod() ... // by pointer
// this means the method operates on the original data in memory
func (p *MyStruct) MyMethod() string {
  return "Executing " + p.MyField
}

// this helper struct is used to inspect an interface value
// it represents the internal memory layout of an interface variable
// 'tab' points to a table with information about the interface and its methods
//
// type iface struct {
// // pointer to the 'itab' struct, see below
// tab *itab
// // here will be a pointer to the concrete value (myObj)
// data unsafe.Pointer
// }
//
// type itab struct {
// // pointer to the 'type MyInterface interface'
// inter *interfacetype
// // pointer to the 'type MyStruct struct'
// typ *rtype
// // in our case we have 1 method, thus '[N]uintptr' == [1]uintptr
// // fun[0] will contain the address of the 'MyMethod' implementation
// fun [N]uintptr // will have '[1]uintptr', and
// }
type ifaceStruct struct {
  // Pointer to type/method info table
  tab unsafe.Pointer
  // Pointer to the actual data
  // in our case, here will be a pointer to the 'myObj' struct
  data unsafe.Pointer
}

// HERE IS THE "MAGIC"
// We modify sayHello to inspect the `g` it receives.
//
// 'g' is a new, local variable of the 'MyInterface' type.
// when the function is called, `myObj` is assigned to `g`.
//
// because 'g' is an interface value, it internally consists of two pointers:
// 1. tab: A pointer to the "interface table" (itab) that links
// the interface type (MyInterface) to the concrete type (*MyStruct)
// and stores pointers to the methods that satisfy the interface
// 2. data: A pointer to the actual data. In our case, this will be
// the pointer we passed in (`myObj`).
func sayHello(g MyInterface) {
  fmt.Println("Inside sayHello()")

  // Get the address of `g` and cast it to our helper struct 'ifaceStruct'

  // This line does three things in one go:
  // 1. &amp;amp;g - takes the memory address of our interface variable `g`
  // 2. unsafe.Pointer(&amp;amp;g) - casts that address to a raw, untyped pointer
  // 3. (*ifaceStruct)(...) - re-interprets that raw pointer as a pointer to our helper struct

  // As a result, `g_internal` is now a `*ifaceStruct` that points to
  // the exact same memory location as `g`, letting us access its .tab and .data fields.
  g_internal := (*ifaceStruct)(unsafe.Pointer(&amp;amp;g))

  fmt.Printf("Internal 'Type' pointer (tab): %p\n", g_internal.tab)
  fmt.Printf("Internal 'Data' pointer (data): %p\n", g_internal.data)
  fmt.Println("Result:", g.MyMethod())
}

func main() {
  // Create the object and get a pointer to it
  // 'myObj' now holds a pointer to a MyStruct instance in memory
  myObj := &amp;amp;MyStruct{MyField: "Hello, Interface!"}

  // Print location of the 'myObj' struct
  fmt.Println("Inside main()")
  fmt.Printf("Address of the original 'myObj' in main(): %p\n", myObj)

  // Pass the pointer to the function
  // i.e. we pass an address of the 'myObj' struct location
  sayHello(myObj)
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;em&gt;Note&lt;/em&gt;&lt;/strong&gt;: &lt;em&gt;in modern Go, &lt;code&gt;itab&lt;/code&gt; was moved to the ABI package, see &lt;a href="https://cs.opensource.google/go/go/+/master:src/internal/abi/iface.go;l=14?q=itab&amp;amp;ss=go%2Fgo" rel="noopener noreferrer"&gt;src/internal/abi/iface.go&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Run the code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ go run interface-details.go
Inside main()
Address of the original 'myObj' in main(): 0xc000014070
Inside sayHello()
Internal 'Type' pointer (tab): 0x4e5a28
Internal 'Data' pointer (data): 0xc000014070
Result: Executing Hello, Interface!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comments above describe most of the details, but in short:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;in the &lt;code&gt;sayHello(myObj)&lt;/code&gt; call we pass the address "&lt;em&gt;0xc000014070&lt;/em&gt;" to the function &lt;code&gt;sayHello()&lt;/code&gt; - a reference to the structure &lt;code&gt;MyStruct&lt;/code&gt; with the field &lt;code&gt;MyField&lt;/code&gt;, which contains the value "&lt;em&gt;Hello, Interface!&lt;/em&gt; "&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sayHello()&lt;/code&gt; function takes an argument of type interface, and the variable &lt;code&gt;g&lt;/code&gt; contains two pointers: &lt;code&gt;tab&lt;/code&gt; (to the structure &lt;code&gt;itab&lt;/code&gt;, which stores information about the type and methods), and &lt;code&gt;data&lt;/code&gt; (to the value of type &lt;code&gt;MyStruct&lt;/code&gt;)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We can try to visualize it like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MyInterface (variable 'g')
+----------------------------------------+
| tab =&amp;gt; itab(MyInterface, *MyStruct)
| data =&amp;gt; &amp;amp;MyStruct{"Hello, Interface!"}
+----------------------------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the most interesting “magic” happens at compile time, when the &lt;code&gt;itab&lt;/code&gt; structure is created:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go checks the methods in the code, finds the structure &lt;code&gt;MyStruct&lt;/code&gt; with the method &lt;code&gt;MyMethod()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;checks the interfaces and finds &lt;code&gt;MyInterface&lt;/code&gt;, which requires the method &lt;code&gt;MyMethod() string&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;checks that &lt;code&gt;MyStruct.MyMethod()&lt;/code&gt; and &lt;code&gt;MyInterface.MyMethod()&lt;/code&gt; match&lt;/li&gt;
&lt;li&gt;creates an interface table (&lt;code&gt;itab&lt;/code&gt; - &lt;em&gt;interface table&lt;/em&gt;) that links &lt;code&gt;MyStruct&lt;/code&gt; to &lt;code&gt;MyInterface&lt;/code&gt; and stores the addresses of the methods that implement the interface&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And then, when executing the program during the call &lt;code&gt;sayHello(myObj)&lt;/code&gt;, Go creates a new variable &lt;code&gt;g&lt;/code&gt; of type &lt;code&gt;iface&lt;/code&gt;, in which these two pointers (&lt;code&gt;tab&lt;/code&gt; and &lt;code&gt;data&lt;/code&gt;) are combined:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the pointer to &lt;code&gt;itab&lt;/code&gt; (which the compiler created for the pair &lt;code&gt;MyStruct&lt;/code&gt; + &lt;code&gt;MyInterface&lt;/code&gt;) will be placed in &lt;code&gt;g.tab&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;a pointer to &lt;code&gt;myObj&lt;/code&gt; (i.e., an address of type "&lt;em&gt;0xc000014070&lt;/em&gt;") will be placed in &lt;code&gt;g.data&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As a result, in &lt;code&gt;g.tab&lt;/code&gt; we will have the structure &lt;code&gt;itab&lt;/code&gt;, and in the field &lt;code&gt;fun[0]&lt;/code&gt; will be the address of the function &lt;code&gt;MyMethod()&lt;/code&gt;, and in &lt;code&gt;g.data&lt;/code&gt; will contain a pointer to an instance of &lt;code&gt;MyStruct&lt;/code&gt; with the field &lt;code&gt;MyField&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And then when calling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
fmt.Println("\nResult:", g.MyMethod())
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We are actually making a call like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
return "Executing " + *MyStruct.MyField
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, we can also inspect the &lt;code&gt;itab&lt;/code&gt; itself by defining a helper our own structure &lt;code&gt;itabStruct&lt;/code&gt;, similar to how we inspected the interface value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;package main

import (
  "fmt"
  "unsafe"
)

// define MyInterface interface
type MyInterface interface {
  MyMethod() string
}

// define MyStruct struct
type MyStruct struct {
  MyField string
}

// define MyMethod method with a POINTER receiver
func (p *MyStruct) MyMethod() string {
  return "Executing " + p.MyField
}

// This helper represents the interface value itself (the 2-word struct)
type ifaceStruct struct {
  // Pointer to the 'itab' (interface table)
  tab unsafe.Pointer
  // Pointer to the actual data (our *MyStruct)
  data unsafe.Pointer
}

// NEW
// this helper represents the internal 'runtime.itab' structure
type itabStruct struct {
  // inter: Pointer to the interface type's definition (MyInterface)
  inter unsafe.Pointer
  // typ: Pointer to the concrete type's definition (*MyStruct)
  typ unsafe.Pointer
  // hash: hash of the concrete type, used internally for lookups
  hash uint32
  // _ [4]byte: Padding (on 64-bit systems)
  _ [4]byte
  // fun: method dispatch table - an array of function pointers
  // each entry corresponds to a method defined in the interface
  // here it stores the address of the concrete MyMethod implementation
  fun [1]uintptr
}

// HERE IS THE "MAGIC"
func sayHello(g MyInterface) {
  fmt.Println("--- Inside sayHello() ---")

  // 1. Get the address of `g` and cast it to our helper struct
  g_internal := (*ifaceStruct)(unsafe.Pointer(&amp;amp;g))

  // Print the two main pointers
  fmt.Printf("g.tab (pointer to itab): %p\n", g_internal.tab)
  fmt.Printf("g.data (pointer to myObj): %p\n", g_internal.data)

  // NEW - 2. DE-REFERENCE THE 'tab' POINTER
  // Cast the 'tab' pointer to our itabStruct pointer
  itab_ptr := (*itabStruct)(g_internal.tab)

  // NEW - 3. PRINT THE CONTENTS OF THE 'itab'
  fmt.Println("\n--- Inspecting the 'itab' (at address g.tab) ---")
  fmt.Printf("itab.inter (ptr to MyInterface info): %p\n", itab_ptr.inter)
  fmt.Printf("itab.typ (ptr to *MyStruct info): %p\n", itab_ptr.typ)
  fmt.Printf("itab.hash (hash of *MyStruct type): %x\n", itab_ptr.hash)

  // This is the final link in the chain: 
  // the actual memory address of the method that will be invoked when g.MyMethod() is called.
  fmt.Printf("itab.fun[0] (ADDRESS OF THE METHOD): 0x%x\n", itab_ptr.fun[0])

  // 4. Call the method as usual
  fmt.Println("\nResult:", g.MyMethod())
}

func main() {
  // Create the object and get a pointer to it
  myObj := &amp;amp;MyStruct{MyField: "Hello, Interface!"}

  fmt.Println("--- Inside main() ---")
  fmt.Printf("Address of original 'myObj' in main(): %p\n", myObj)

  // Pass the pointer to the function - Go will create an 'iface' value
  // linking the interface 'MyInterface' with the concrete type *MyStruct.
  sayHello(myObj)
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ go run interface-details-3.go
--- Inside main() ---
Address of original 'myObj' in main(): 0xc00019a020
--- Inside sayHello() ---
g.tab (pointer to itab): 0x4e6c08
g.data (pointer to myObj): 0xc00019a020

--- Inspecting the 'itab' (at address g.tab) ---
itab.inter (ptr to MyInterface info): 0x4a9d80
itab.typ (ptr to *MyStruct info): 0x4a86e0
itab.hash (hash of *MyStruct type): 1ac3179f
itab.fun[0] (ADDRESS OF THE METHOD): 0x499c40

Result: Executing Hello, Interface!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is, when we call &lt;code&gt;g.MyMethod()&lt;/code&gt;, Go takes the function address from &lt;code&gt;itab.fun[0]&lt;/code&gt; and invokes it, passing it a pointer from &lt;code&gt;g.data&lt;/code&gt; as an argument - that's all there is to the "magic" of dynamic method calls via an interface.&lt;/p&gt;

&lt;p&gt;Now you can use the interfaces, already having an idea of how they work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Useful links
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.alexedwards.net/blog/interfaces-explained" rel="noopener noreferrer"&gt;Golang Interfaces explained&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/@jamal.kaksouri/understanding-the-power-of-go-interfaces-a-comprehensive-guide-835954101b7e" rel="noopener noreferrer"&gt;Understanding the Power of Go Interfaces: A Comprehensive Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/@AlexanderObregon/go-interfaces-behind-the-scenes-f8812706f2c5" rel="noopener noreferrer"&gt;Go Interfaces Behind the Scenes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/golang/go/blob/master/src/runtime/runtime2.go#L184" rel="noopener noreferrer"&gt;https://github.com/golang/go/blob/master/src/runtime/runtime2.go#L184&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://research.swtch.com/interfaces" rel="noopener noreferrer"&gt;Go Data Structures: Interfaces&lt;/a&gt; (2009)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tul.github.io/2018/07/23/go-interfaces-deep-dive.html" rel="noopener noreferrer"&gt;Diving into some Golang interface internals&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;/em&gt; &lt;a href="https://rtfm.co.ua/en/golang-interfaces-the-magic-of-calling-methods-through-interface/" rel="noopener noreferrer"&gt;&lt;em&gt;RTFM: Linux, DevOps, and system administration&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>go</category>
      <category>programming</category>
      <category>todayilearned</category>
    </item>
    <item>
      <title>Golang: creating OpenAI Exporter for VictoriaMetrics</title>
      <dc:creator>Arseny Zinchenko</dc:creator>
      <pubDate>Sun, 23 Nov 2025 11:27:50 +0000</pubDate>
      <link>https://dev.to/setevoy/golang-creating-openai-exporter-for-victoriametrics-2anf</link>
      <guid>https://dev.to/setevoy/golang-creating-openai-exporter-for-victoriametrics-2anf</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0o2f41makukog27st7x7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0o2f41makukog27st7x7.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Got a new task to monitor costs on OpenAI - to see how much each project spends per day, and send alerts to Slack if costs exceed a set threshold.&lt;/p&gt;

&lt;p&gt;I tried several existing exporters for the OpenAI API, but none provided cost metrics per project - so we’ll build one ourselves.&lt;/p&gt;

&lt;h3&gt;
  
  
  Contents
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-creating-openai-exporter-for-victoriametrics/#OpenAI_API" rel="noopener noreferrer"&gt;OpenAI API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-creating-openai-exporter-for-victoriametrics/#Golang_gore_REPL" rel="noopener noreferrer"&gt;Golang gore REPL&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-creating-openai-exporter-for-victoriametrics/#Creating_a_Golang_API_client" rel="noopener noreferrer"&gt;Creating a Golang API client&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-creating-openai-exporter-for-victoriametrics/#Creating_a_resty_client" rel="noopener noreferrer"&gt;Creating a resty client&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-creating-openai-exporter-for-victoriametrics/#resty_JSON_Unmarshall" rel="noopener noreferrer"&gt;resty JSON Unmarshall&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-creating-openai-exporter-for-victoriametrics/#Creating_a_Go_struct_for_JSON_Unmarshall" rel="noopener noreferrer"&gt;Creating a Go struct for JSON Unmarshall&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-creating-openai-exporter-for-victoriametrics/#Go_struct_for_the_Project_ID_and_Amount" rel="noopener noreferrer"&gt;Go struct for the Project ID and Amount&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-creating-openai-exporter-for-victoriametrics/#Go_struct_for_the_Project_Names" rel="noopener noreferrer"&gt;Go struct for the Project Names&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-creating-openai-exporter-for-victoriametrics/#sanitizing_names_%E2%80%93_formatting_values_with_stringsReplace" rel="noopener noreferrer"&gt;sanitizing names - formatting values with strings.Replace()&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-creating-openai-exporter-for-victoriametrics/#Planning_metrics_for_VictoriaMetrics" rel="noopener noreferrer"&gt;Planning metrics for VictoriaMetrics&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-creating-openai-exporter-for-victoriametrics/#VictoriaMetrics_Go_client" rel="noopener noreferrer"&gt;VictoriaMetrics Go client&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-creating-openai-exporter-for-victoriametrics/#Create_metrics_with_NewGauge" rel="noopener noreferrer"&gt;Create metrics with NewGauge()&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-creating-openai-exporter-for-victoriametrics/#Recording_metrics_to_VictoriaMetrics_with_InitPush_and_PushMetrics" rel="noopener noreferrer"&gt;Recording metrics to VictoriaMetrics with InitPush() and PushMetrics()&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-creating-openai-exporter-for-victoriametrics/#Inside_the_InitPush_implementation" rel="noopener noreferrer"&gt;Inside the InitPush() implementation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-creating-openai-exporter-for-victoriametrics/#VictoriaMetrics_and_the_Set_struct" rel="noopener noreferrer"&gt;VictoriaMetrics and the Set struct&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-creating-openai-exporter-for-victoriametrics/#Inside_the_PushMetrics_function_implementation" rel="noopener noreferrer"&gt;Inside the PushMetrics() function implementation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-creating-openai-exporter-for-victoriametrics/#Creating_context_and_calling_PushMetrics" rel="noopener noreferrer"&gt;Creating context and calling PushMetrics()&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-creating-openai-exporter-for-victoriametrics/#Using_gocron_for_tasks_scheduling" rel="noopener noreferrer"&gt;Using gocron for tasks scheduling&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-creating-openai-exporter-for-victoriametrics/#Creating_a_Golang_context" rel="noopener noreferrer"&gt;Creating a Golang context&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-creating-openai-exporter-for-victoriametrics/#The_Final_result_of_the_OpenAI_Exporter_code" rel="noopener noreferrer"&gt;The Final result of the OpenAI Exporter code&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-creating-openai-exporter-for-victoriametrics/#Bonus_How_execution_control_works_with_Go_context" rel="noopener noreferrer"&gt;Bonus: How execution control works with Go context&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-creating-openai-exporter-for-victoriametrics/#Closing_the_channel_with_cancel" rel="noopener noreferrer"&gt;Closing the channel with cancel()&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/golang-creating-openai-exporter-for-victoriametrics/#How_the_context_channel_is_created" rel="noopener noreferrer"&gt;How the context channel is created&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To create the exporter, let’s use Golang. The idea is simple: get data from the OpenAI API, generate metrics, and send them to VictoriaMetrics instance.&lt;/p&gt;

&lt;p&gt;I last used Go in 2019 (just a single time), so we’ll refresh how things work and occasionally dive into the internals of some libraries.&lt;/p&gt;

&lt;p&gt;So, the primary goal of this post is to demonstrate how to build an API client in Go, work with Go structs and JSON, and use the &lt;a href="https://github.com/VictoriaMetrics/metrics" rel="noopener noreferrer"&gt;VictoriaMetrics Go client&lt;/a&gt; - including a look at how it works under the hood - to generate metrics and push them into a VictoriaMetrics instance for further use in VMAlert.&lt;/p&gt;

&lt;p&gt;Also, it’s worth checking out the &lt;a href="https://victoriametrics.com/blog/tags/golang/" rel="noopener noreferrer"&gt;VictoriaMetrics blog&lt;/a&gt; - their team has been working with Go for many years and offers a lot of in-depth, practical Go content.&lt;/p&gt;

&lt;p&gt;Let’s go.&lt;/p&gt;

&lt;h3&gt;
  
  
  OpenAI API
&lt;/h3&gt;

&lt;p&gt;OpenAI API documentation - &lt;a href="https://platform.openai.com/docs/api-reference/usage/costs" rel="noopener noreferrer"&gt;Costs&lt;/a&gt;, and its returned value - &lt;a href="https://platform.openai.com/docs/api-reference/usage/costs_object" rel="noopener noreferrer"&gt;Costs object&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;To access Costs, you need a separate key - create it at &lt;a href="https://platform.openai.com/" rel="noopener noreferrer"&gt;platform.openai.com&lt;/a&gt; in Admin keys:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffio5eu3djukm4fw8mbzc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffio5eu3djukm4fw8mbzc.png" width="250" height="299"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To obtain Costs, you need to set the &lt;code&gt;start_time&lt;/code&gt; parameter in Unix format. Let's set it to a variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ TODAY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y-%m-%d&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; 00:00:00"&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$TODAY&lt;/span&gt;
1762898400
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And check access with &lt;code&gt;curl&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;curl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;-s&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;-H&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;-H&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Authorization: Bearer $OPENAI_ADMIN_KEY"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.openai.com/v1/organization/costs?start_time=$TODAY"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"page"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"has_more"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"next_page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bucket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"start_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1762819200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"end_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1762905600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"organization.costs.result"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;5.65750295&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"usd"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;OK, access is working - let’s go to Golang.&lt;/p&gt;

&lt;h3&gt;
  
  
  Golang gore REPL
&lt;/h3&gt;

&lt;p&gt;I prefer working in the console, and since my primary language is Python - I really like being able to run quick experiments using terminal-based tools.&lt;/p&gt;

&lt;p&gt;For Go, we can use the &lt;a href="https://github.com/x-motemen/gore" rel="noopener noreferrer"&gt;&lt;code&gt;gore&lt;/code&gt;&lt;/a&gt; package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;go &lt;span class="nb"&gt;install &lt;/span&gt;github.com/x-motemen/gore/cmd/gore@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run (don’t forget to add &lt;code&gt;$GOPATH/bin&lt;/code&gt; to the &lt;code&gt;$PATH&lt;/code&gt;) and check that you are getting the current date and time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ gore
gore version 0.6.1 :help for help
gore&amp;gt; :import time
gore&amp;gt; time.Now()
%!t(int64=1763025027)2025-11-13 11:10:27 Local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or just use &lt;a href="https://go.dev/play/" rel="noopener noreferrer"&gt;Go Playground&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating a Golang API client
&lt;/h3&gt;

&lt;p&gt;First, we will write a client that will call the OpenAI API and display the result on the console, and then we will add VictoriaMetrics metric generation.&lt;/p&gt;

&lt;p&gt;What we need for API requests:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;have a URL&lt;/li&gt;
&lt;li&gt;have time&lt;/li&gt;
&lt;li&gt;for now, we’ll just output the result to the console&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Create a project directory and perform initialization:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; ~/Work/atlas-monitoring/exporters/openai-exporter
&lt;span class="nv"&gt;$ &lt;/span&gt;go mod init openai-exporter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the API client, we can use the standard library &lt;a href="https://pkg.go.dev/net/http" rel="noopener noreferrer"&gt;&lt;code&gt;net/http&lt;/code&gt;&lt;/a&gt;, or more specialized types such as &lt;a href="https://github.com/go-resty/resty" rel="noopener noreferrer"&gt;&lt;code&gt;resty&lt;/code&gt;&lt;/a&gt; or &lt;a href="https://github.com/dghubble/sling" rel="noopener noreferrer"&gt;&lt;code&gt;sling&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I decided to try &lt;code&gt;resty&lt;/code&gt; because it's interesting, the code looks nicer, and it has a nice way to pass parameters.&lt;/p&gt;

&lt;p&gt;Documentation on &lt;code&gt;resty&lt;/code&gt; - &lt;a href="https://resty.dev" rel="noopener noreferrer"&gt;here&amp;gt;&amp;gt;&amp;gt;&lt;/a&gt; and &lt;a href="https://pkg.go.dev/github.com/go-resty/resty/v2" rel="noopener noreferrer"&gt;here&amp;gt;&amp;gt;&amp;gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Restyu version 3 is already available, but it is still in beta, so we will take the second one.&lt;/p&gt;

&lt;p&gt;Let’s try with &lt;code&gt;resty&lt;/code&gt; first in &lt;code&gt;gore&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gore&amp;gt; :import "github.com/go-resty/resty/v2"
gore&amp;gt; client := resty.New()
...
gore&amp;gt; resp, err := client.R().Get("https://httpbin.org/get")
...
gore&amp;gt; fmt.Println(resp, err)
...
313
nil
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To execute API requests, first call the &lt;a href="https://pkg.go.dev/github.com/go-resty/resty/v2#New" rel="noopener noreferrer"&gt;&lt;code&gt;New()&lt;/code&gt;&lt;/a&gt; method to create an object of the &lt;a href="https://pkg.go.dev/github.com/go-resty/resty/v2#Client" rel="noopener noreferrer"&gt;&lt;code&gt;type Client struct&lt;/code&gt;&lt;/a&gt;, and then use the &lt;a href="https://pkg.go.dev/github.com/go-resty/resty/v2#Client.R" rel="noopener noreferrer"&gt;&lt;code&gt;R()&lt;/code&gt;&lt;/a&gt; (request) method to make calls.&lt;/p&gt;

&lt;p&gt;Documentation for &lt;code&gt;New()&lt;/code&gt; &lt;a href="https://pkg.go.dev/github.com/go-resty/resty/v2#New" rel="noopener noreferrer"&gt;here&amp;gt;&amp;gt;&amp;gt;&lt;/a&gt;, its code &lt;a href="https://github.com/go-resty/resty/blob/v2/resty.go" rel="noopener noreferrer"&gt;here&amp;gt;&amp;gt;&amp;gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In Go, types don’t explicitly list their methods in the source, but all associated methods are clearly visible through &lt;code&gt;go doc&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;go doc github.com/go-resty/resty/v2.Client | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"New()&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;R()"&lt;/span&gt;
func New&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;Client
func &lt;span class="o"&gt;(&lt;/span&gt;c &lt;span class="k"&gt;*&lt;/span&gt;Client&lt;span class="o"&gt;)&lt;/span&gt; R&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;Request
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://pkg.go.dev/github.com/go-resty/resty/v2#readme-usage" rel="noopener noreferrer"&gt;Usage&lt;/a&gt; section contains the following example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
resp, err := client.R().
    EnableTrace().
    Get("https://httpbin.org/get")
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where &lt;code&gt;resty&lt;/code&gt; uses &lt;em&gt;method chaining&lt;/em&gt;, when methods of a certain type return the same type.&lt;/p&gt;

&lt;p&gt;How it looks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;with the &lt;code&gt;resty.New()&lt;/code&gt; function, we create a client - &lt;code&gt;New()&lt;/code&gt; returns &lt;a href="https://pkg.go.dev/github.com/go-resty/resty/v2#Client" rel="noopener noreferrer"&gt;&lt;code&gt;*Client struct&lt;/code&gt;&lt;/a&gt; with its associated methods.&lt;/li&gt;
&lt;li&gt;for &lt;code&gt;Client&lt;/code&gt; struct, there is a method &lt;a href="https://pkg.go.dev/github.com/go-resty/resty/v2#Client.R" rel="noopener noreferrer"&gt;&lt;code&gt;R()&lt;/code&gt;&lt;/a&gt; that returns &lt;a href="https://pkg.go.dev/github.com/go-resty/resty/v2#Request" rel="noopener noreferrer"&gt;&lt;code&gt;*Request struct&lt;/code&gt;&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;for the &lt;code&gt;Request&lt;/code&gt; structure, we have the &lt;a href="https://pkg.go.dev/github.com/go-resty/resty/v2#Request.EnableTrace" rel="noopener noreferrer"&gt;&lt;code&gt;EnableTrace()&lt;/code&gt;&lt;/a&gt; method, which also returns &lt;code&gt;Request&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;and for the same &lt;code&gt;Request&lt;/code&gt; we have the &lt;a href="https://pkg.go.dev/github.com/go-resty/resty/v2#Request.Get" rel="noopener noreferrer"&gt;&lt;code&gt;Get()&lt;/code&gt;&lt;/a&gt; method, which also returns &lt;code&gt;Request&lt;/code&gt; plus &lt;code&gt;error&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This allows us to build chained calls such as: &lt;code&gt;Client&lt;/code&gt; =&amp;gt; &lt;code&gt;R()&lt;/code&gt; =&amp;gt; &lt;code&gt;Request&lt;/code&gt; =&amp;gt; &lt;code&gt;EnableTrace()&lt;/code&gt; =&amp;gt; &lt;code&gt;Request&lt;/code&gt; =&amp;gt; &lt;code&gt;Get()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Okay, let’s get to the code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating a &lt;code&gt;resty&lt;/code&gt; client
&lt;/h3&gt;

&lt;p&gt;Create a &lt;code&gt;main.go&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s"&gt;"fmt"&lt;/span&gt;

  &lt;span class="s"&gt;"github.com/go-resty/resty/v2"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;// set global const as ay be used in other packages&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;baseURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://api.openai.com/v1"&lt;/span&gt;
  &lt;span class="n"&gt;costsPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/organization/costs"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;resty&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="c"&gt;// build 'https://api.openai.com/v1/organization/costs'&lt;/span&gt;
  &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;R&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;baseURL&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;costsPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;panic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it to test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;go run main.go
&lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"error"&lt;/span&gt;: &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"message"&lt;/span&gt;: &lt;span class="s2"&gt;"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."&lt;/span&gt;
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Great, it works.&lt;/p&gt;

&lt;p&gt;Now let’s add getting the API key from a variable.&lt;/p&gt;

&lt;p&gt;Use &lt;a href="https://pkg.go.dev/os" rel="noopener noreferrer"&gt;&lt;code&gt;os&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s"&gt;"fmt"&lt;/span&gt;
  &lt;span class="s"&gt;"os"&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;resty&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="n"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"OPENAI_ADMIN_KEY"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, we need to add an auth header to our request, this can be done using the &lt;a href="https://pkg.go.dev/github.com/go-resty/resty/v2#Client.SetAuthToken" rel="noopener noreferrer"&gt;&lt;code&gt;func (*Client) SetAuthToken&lt;/code&gt;&lt;/a&gt; method, which simply adds a value to the &lt;code&gt;Token&lt;/code&gt; field in the &lt;code&gt;Client&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There is also a separate method &lt;a href="https://pkg.go.dev/github.com/go-resty/resty/v2#Request.SetAuthToken" rel="noopener noreferrer"&gt;&lt;code&gt;func (r *Request) SetAuthToken&lt;/code&gt;&lt;/a&gt;, which sets the token for specific requests rather than for the entire client, but in our case, we make it simpler by using the general &lt;code&gt;Client&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Let’s do method chaining from the example above - for &lt;code&gt;Client&lt;/code&gt; we call &lt;a href="https://pkg.go.dev/github.com/go-resty/resty/v2#Request.SetAuthToken" rel="noopener noreferrer"&gt;&lt;code&gt;SetAuthToken()&lt;/code&gt;&lt;/a&gt;, which sets the token, then call &lt;code&gt;R()&lt;/code&gt; to create a request, and then call &lt;code&gt;Get()&lt;/code&gt;, to which we pass the URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="n"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"OPENAI_ADMIN_KEY"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="c"&gt;// build 'https://api.openai.com/v1/organization/costs'&lt;/span&gt;
  &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetAuthToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;R&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;baseURL&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;costsPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let’s check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;go run main.go
&lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"error"&lt;/span&gt;: &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"message"&lt;/span&gt;: &lt;span class="s2"&gt;"Missing query parameter 'start_time'"&lt;/span&gt;,
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;OK, we’ve passed authentication, now we need to add the parameters.&lt;/p&gt;

&lt;p&gt;Here we have four options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://pkg.go.dev/github.com/go-resty/resty/v2#Request.SetQueryParam" rel="noopener noreferrer"&gt;func (r *Request) SetQueryParam(param, value string) *Request&lt;/a&gt;: sets one parameter key=value, can be used if there are only 1–2 parameters in total&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pkg.go.dev/github.com/go-resty/resty/v2#Request.SetQueryParams" rel="noopener noreferrer"&gt;func (r *Request) SetQueryParams(params &lt;code&gt;map&lt;/code&gt;[string]string) *Request&lt;/a&gt;: similar, but accepts a map with a list of parameters&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pkg.go.dev/github.com/go-resty/resty/v2#Request.SetQueryParamsFromValues" rel="noopener noreferrer"&gt;func (r *Request) SetQueryParamsFromValues(params &lt;code&gt;url.Values&lt;/code&gt;) *Request&lt;/a&gt;: if the &lt;code&gt;http&lt;/code&gt; library is used, parameters can be passed via the &lt;a href="https://pkg.go.dev/net/url#Values" rel="noopener noreferrer"&gt;url.Values&lt;/a&gt; type&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pkg.go.dev/github.com/go-resty/resty/v2#Request.SetQueryString" rel="noopener noreferrer"&gt;func (r *Request) SetQueryString(query string) *Request&lt;/a&gt;: passes a ready list of parameters in a single string, for example - &lt;code&gt;SetQueryString("a=1&amp;amp;b=2")&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Right now, we only need &lt;code&gt;start_time&lt;/code&gt;, but we will be adding more parameters later, so we can write them all to a map, which we will then pass to a &lt;a href="https://pkg.go.dev/github.com/go-resty/resty/v2#Request.SetQueryParams" rel="noopener noreferrer"&gt;&lt;code&gt;SetQueryParams()&lt;/code&gt;&lt;/a&gt; call.&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;start_time&lt;/code&gt;, we need to pass the time - do this with &lt;a href="https://pkg.go.dev/time#Now" rel="noopener noreferrer"&gt;&lt;code&gt;time.Now()&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The time to the OpenAI API must be provided in the Unix format, so add the &lt;a href="https://pkg.go.dev/time#Unix" rel="noopener noreferrer"&gt;&lt;code&gt;Unix()&lt;/code&gt;&lt;/a&gt; function.&lt;/p&gt;

&lt;p&gt;Let’s check how it will look in the &lt;code&gt;gore&lt;/code&gt; session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gore&amp;gt; :import time
gore&amp;gt; timeNow := time.Now().Unix()
1762956432
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now add a code to create the &lt;code&gt;timeNow&lt;/code&gt; variable with the time, create &lt;code&gt;setQueryParams&lt;/code&gt; map of strings with a list of parameters also in strings, and add the &lt;code&gt;SetQueryParams()&lt;/code&gt; call to the &lt;code&gt;client&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="n"&gt;timeNow&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unix&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="n"&gt;setQueryParams&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s"&gt;"start_time"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timeNow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c"&gt;// build 'https://api.openai.com/v1/organization/costs'&lt;/span&gt;
  &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetAuthToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;R&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetQueryParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;setQueryParams&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;baseURL&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;costsPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But if you call this code now, you’ll get an error because &lt;code&gt;timeNow := time.Now().Unix()&lt;/code&gt; returns an &lt;code&gt;int64&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gore&amp;gt; fmt.Printf("%t", timeNow)
%!t(int64=1762957173)21
nil
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in the &lt;code&gt;setQueryParams()&lt;/code&gt;, we need to pass a string because &lt;code&gt;SetQueryParams()&lt;/code&gt; accepts a map with a string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;SetQueryParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Therefore, convert our variable &lt;code&gt;timeNow&lt;/code&gt; to a string using &lt;a href="https://pkg.go.dev/strconv#FormatInt" rel="noopener noreferrer"&gt;&lt;code&gt;strconv.FormatInt()&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gore&amp;gt; :import strconv
gore&amp;gt; s := strconv.FormatInt(timeNow, 10)
gore&amp;gt; fmt.Printf("%t", s)
%!t(string=1763371451)22
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And now, our variable &lt;code&gt;timeNow&lt;/code&gt; looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="n"&gt;timeNow&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strconv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FormatInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unix&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run, and check the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;go&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;run&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;main.go&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"page"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"has_more"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"next_page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bucket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"start_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1762905600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"end_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1762992000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"organization.costs.result"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;6.442440250000003&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"usd"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"line_item"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"project_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"organization_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"org-ORG"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Great, we have the data we need.&lt;/p&gt;

&lt;p&gt;Now we have to add one more parameter  - &lt;code&gt; group_by=project_id&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="n"&gt;setQueryParams&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s"&gt;"start_time"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timeNow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"group_by"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"project_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And after this, we have data for each &lt;code&gt;project_id&lt;/code&gt; in the results:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;go&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;run&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;main.go&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"page"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"has_more"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"next_page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bucket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"start_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1762905600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"end_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1762992000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"organization.costs.result"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.76643575&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"usd"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"line_item"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"project_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"proj_1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"organization_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"org-ORG"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"organization.costs.result"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.47790999999999995&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"usd"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"line_item"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"project_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"proj_2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"organization_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"org-ORG"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, we need to store the result in a variable for further work.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;resty&lt;/code&gt; JSON Unmarshall
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;resty&lt;/code&gt; supports automatic JSON unmarshalling via &lt;a href="https://pkg.go.dev/github.com/go-resty/resty/v2#Request.SetResult" rel="noopener noreferrer"&gt;&lt;code&gt;SetResult()&lt;/code&gt;&lt;/a&gt; method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;SetResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;getPointer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It accepts the argument type any (&lt;code&gt;interface{}&lt;/code&gt;), passes it to its function &lt;a href="https://github.com/go-resty/resty/blob/v2.16.5/util.go#L240" rel="noopener noreferrer"&gt;&lt;code&gt;getPointer()&lt;/code&gt;&lt;/a&gt;, where it checks whether it is a pointer type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;getPointer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;vv&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;valueOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;vv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Kind&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;reflect&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ptr&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, &lt;code&gt;SetResult()&lt;/code&gt; calls &lt;code&gt;parseResponseBody()&lt;/code&gt; to write the value from &lt;code&gt;Request.Result&lt;/code&gt; to the object that was passed as an argument to &lt;code&gt;SetResult()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="c"&gt;// default after response middlewares&lt;/span&gt;
  &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;afterResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;ResponseMiddleware&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;parseResponseBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;saveResponseIntoFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in the function &lt;a href="https://github.com/go-resty/resty/blob/v2.16.5/middleware.go#L397" rel="noopener noreferrer"&gt;&lt;code&gt;parseResponseBody()&lt;/code&gt;&lt;/a&gt; the method &lt;code&gt;Unmarshalc&lt;/code&gt; is called, which then calls &lt;code&gt;Client.JSONUnmarshal()&lt;/code&gt;, and the field &lt;code&gt;JSONUnmarshal&lt;/code&gt; contains the function &lt;code&gt;json.Unmarshal()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hc&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;hc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Transport&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;hc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Transport&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;createTransport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c"&gt;// not setting lang default values&lt;/span&gt;
                &lt;span class="o"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;JSONUnmarshal&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;See source code of the &lt;a href="https://github.com/go-resty/resty/blob/v2/client.go#L1464" rel="noopener noreferrer"&gt;&lt;code&gt;resty/v2/client.go&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So, we get the result in JSON, and by using &lt;code&gt;SetResult()&lt;/code&gt; we can save the necessary fields in some object.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating a Go struct for JSON Unmarshall
&lt;/h3&gt;

&lt;p&gt;Let’s think about how we want to structure the data.&lt;/p&gt;

&lt;p&gt;We have &lt;code&gt;project_id&lt;/code&gt; and &lt;code&gt;amount&lt;/code&gt; - how much this project has spent, which we get from the OpenAI API &lt;code&gt;/organization/costs&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;We also have Project Names, which we can get from &lt;code&gt;/organization/projects&lt;/code&gt;, but more on that later.&lt;/p&gt;

&lt;p&gt;As a result, we can build something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"project_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Id1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"project_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Name1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"project_spend"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"project_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Id2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"project_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Name2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"project_spend"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What does Go offer for this?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;array&lt;/strong&gt; , array: fixed length, indexed type, all objects of the same type  - &lt;code&gt; [3]int{1,2,3}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;slice&lt;/strong&gt; : similar to array, but not fixed length  - &lt;code&gt; []int{1,2,3}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;maps&lt;/strong&gt; : a set of key:value elements of variable length of the same type  - &lt;code&gt; map[string]string{"key_name": "value_value"}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;structs&lt;/strong&gt; : a complex type that can include other types  -  &lt;code&gt;struct{ Name string; Age int }{ Name: "Nino", Age: 35 }&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Since we know what types we get from the API and all the fields in them, a slice of structs will work for us, where each element of the slice will be a structure with fields in which we will store &lt;code&gt;project_id&lt;/code&gt;, &lt;code&gt;amount&lt;/code&gt;, and &lt;code&gt;project_name&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Go struct for the Project ID and Amount
&lt;/h3&gt;

&lt;p&gt;The structure for us may look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;ProjectSpend&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;ProjectID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
  &lt;span class="n"&gt;ProjectSpend&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, let’s create a slice with this structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;ProjectSpend&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now let’s take a look at what the OpenAI API returns.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;/organization/costs&lt;/code&gt; response is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"page"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"has_more"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"next_page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bucket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"start_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1763078400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"end_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1763164800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"organization.costs.result"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;2.16911625&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"usd"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"line_item"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"project_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"proj_1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"organization_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"org-ORG"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"organization.costs.result"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.1846203&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"usd"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"line_item"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"project_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"proj_2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"organization_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"org-ORG"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here we have the following structure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;begins with JSON &lt;code&gt;object {}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;has several JSON properties  - &lt;code&gt; "object": "page"&lt;/code&gt;, etc&lt;/li&gt;
&lt;li&gt;followed by an array &lt;code&gt;data []&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;which contains another &lt;code&gt;object {}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;which starts with properties &lt;code&gt;"object": "bucket"&lt;/code&gt;, etc&lt;/li&gt;
&lt;li&gt;and in which there is another array &lt;code&gt;results []&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;which includes another &lt;code&gt;object {}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;which starts with property &lt;code&gt;"object": "organization.costs.result"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;followed by property &lt;code&gt;amount&lt;/code&gt;, which contains a nested &lt;code&gt;object {}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;with two properties  - &lt;code&gt; value&lt;/code&gt; and &lt;code&gt;value&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If we want to reflect this to a Go struct, we need to create several structures that will transfer data to each other:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the first structure “captures” the fitst &lt;code&gt;data[]&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;the second structure receives &lt;code&gt;results[]&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;the third receives the value of the field &lt;code&gt;project_id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;and the fourth reads &lt;code&gt;amount&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;How this might look in code, using struct composition, when one struct contains a field whose type is another struct:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;ResponceAmount&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;Value&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;ResponceProjectID&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;ProjectID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"project_id"`&lt;/span&gt;
  &lt;span class="n"&gt;Amount&lt;/span&gt; &lt;span class="n"&gt;ResponceAmount&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;ResponseResults&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;Results&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;ResponceProjectID&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;ResponseData&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;Data&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;ResponseResults&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;ResponseData&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And now we can execute &lt;code&gt;json.Unmarshall&lt;/code&gt; by calling &lt;code&gt;SetResult()&lt;/code&gt;, to which we pass a pointer - &lt;code&gt;res := &amp;amp;ResponseData{}&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetAuthToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;R&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetQueryParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;setQueryParams&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;SetResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;baseURL&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;costsPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Result: "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ go run main.go
...
Result: &amp;amp;{[{[{proj_1 {2.16911625}} {proj_Agtar0XzJdXXLhGt8YCRNZMY {0.1846203}} {proj_2 {0.1531728}} {proj_3 {0.19788874999999997}}]}]}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or we can make it more concise by using &lt;em&gt;nested anonymous structs&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="c"&gt;// catch data[] and pass to nested struct&lt;/span&gt;
  &lt;span class="c"&gt;// catch results[] and pass to next nested struct&lt;/span&gt;
  &lt;span class="c"&gt;// catch 'project_id' property to the 'ProjectID' field, and pass to next nested struct&lt;/span&gt;
  &lt;span class="c"&gt;// catch 'amount' property to the 'Amount' field, and pass to next nested struct&lt;/span&gt;
  &lt;span class="c"&gt;// finally, catch 'value' property to the 'Value' field&lt;/span&gt;
  &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;ResponseData&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Data&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;Results&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;ProjectID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"project_id"`&lt;/span&gt;
        &lt;span class="n"&gt;Amount&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="n"&gt;Value&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And we will get the same result.&lt;/p&gt;

&lt;p&gt;Next, we will need to generate metrics with labels.&lt;/p&gt;

&lt;p&gt;We do this in two &lt;code&gt;for&lt;/code&gt; loops, in which we iterate through the fields of each structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="c"&gt;// catch each item from the 'Response.Data[]'&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dataItem&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Data&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// catch each iteam from the 'Response.Data[].Results[]'&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;dataItem&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Results&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

      &lt;span class="n"&gt;project&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProjectID&lt;/span&gt;
      &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;

      &lt;span class="c"&gt;// print in VictoriaMetrics gauge format&lt;/span&gt;
      &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"openai_stats{type=&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;costs&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;, project=&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;%s&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;} %f&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;go run main.go
openai_stats&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"costs"&lt;/span&gt;, &lt;span class="nv"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"proj_1"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; 2.170784
openai_stats&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"costs"&lt;/span&gt;, &lt;span class="nv"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"proj_2"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; 0.241411
openai_stats&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"costs"&lt;/span&gt;, &lt;span class="nv"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"proj_3"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; 0.213558
openai_stats&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"costs"&lt;/span&gt;, &lt;span class="nv"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"proj_4"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; 0.198619
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now let’s do the same for project names, because using values like “&lt;em&gt;proj_123&lt;/em&gt;” in metric labels isn’t very useful - it’s better to display human-readable project names instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Go struct for the Project Names
&lt;/h3&gt;

&lt;p&gt;Add a second endpoint, see the documentation &lt;a href="https://platform.openai.com/docs/api-reference/projects/list" rel="noopener noreferrer"&gt;List projects&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;baseURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://api.openai.com/v1"&lt;/span&gt;
  &lt;span class="n"&gt;costsPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/organization/costs"&lt;/span&gt;
  &lt;span class="n"&gt;projectsPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/organization/projects"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And move the execution of requests to OpenAI to a dedicated function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;getOpenAi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;resty&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;R&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;SetResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Move setting the &lt;code&gt;OPENAI_ADMIN_KEY&lt;/code&gt; key and parameters to the client creation call with the &lt;code&gt;resty.New()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Next, call our function &lt;code&gt;getOpenAi()&lt;/code&gt;, and pass the created and configured client as the first argument:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c"&gt;//client := resty.New()&lt;/span&gt;

  &lt;span class="n"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"OPENAI_ADMIN_KEY"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;timeNow&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strconv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FormatInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unix&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;setQueryParams&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s"&gt;"start_time"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timeNow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"group_by"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"project_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c"&gt;// use pointer to ResponseData struct&lt;/span&gt;
  &lt;span class="c"&gt;// as 'json.Unmarshal' requires a pointer to write results&lt;/span&gt;
  &lt;span class="n"&gt;costsRes&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;CostsResponseData&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;resty&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;SetAuthToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;SetQueryParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;setQueryParams&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;getOpenAi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;baseURL&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;costsPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;costsRes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Result: "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;costsRes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run to check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;go run main.go
Result: &amp;amp;&lt;span class="o"&gt;{[{[{&lt;/span&gt;proj_1 &lt;span class="o"&gt;{&lt;/span&gt;2.1707842499999996&lt;span class="o"&gt;}}&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;proj_2 &lt;span class="o"&gt;{&lt;/span&gt;0.24141089999999998&lt;span class="o"&gt;}}&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;proj_3 &lt;span class="o"&gt;{&lt;/span&gt;0.21355799999999994&lt;span class="o"&gt;}}&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;proj_4 &lt;span class="o"&gt;{&lt;/span&gt;0.46123659999999994&lt;span class="o"&gt;}}]}]}&lt;/span&gt;
openai_stats&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"costs"&lt;/span&gt;, &lt;span class="nv"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"proj_1"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; 2.170784
openai_stats&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"costs"&lt;/span&gt;, &lt;span class="nv"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"proj_2"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; 0.241411
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now let’s move on to obtaining project names.&lt;/p&gt;

&lt;p&gt;A request to the &lt;code&gt;api.openai.com/v1/organization/projects&lt;/code&gt; endpoint will return data in the following format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"list"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"proj_abc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"organization.project"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Project example"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1711471533&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"archived_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"active"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"first_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"proj-abc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"last_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"proj-xyz"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"has_more"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We do the same as when obtaining costs - add a structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;ProjectsResponse&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;Data&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in the &lt;code&gt;main()&lt;/code&gt;, add a second call to the &lt;code&gt;getOpenAi()&lt;/code&gt; and errors handling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="c"&gt;// use pointer to ResponseData struct&lt;/span&gt;
  &lt;span class="c"&gt;// as 'json.Unmarshal' requires a pointer to write results&lt;/span&gt;
  &lt;span class="n"&gt;costsRes&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;CostsResponseData&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;getOpenAi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;baseURL&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;costsPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;costsRes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;panic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;projectsRes&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;ProjectsResponse&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;getOpenAi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;baseURL&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;projectsPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;projectsRes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;panic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Costs Result: "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;costsRes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Projects Result: "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;projectsRes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And now, we have the following result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;go run main.go
Costs Result: &amp;amp;&lt;span class="o"&gt;{[{[{&lt;/span&gt;proj_1 &lt;span class="o"&gt;{&lt;/span&gt;2.1707842499999996&lt;span class="o"&gt;}}&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;proj_2 &lt;span class="o"&gt;{&lt;/span&gt;0.24141089999999998&lt;span class="o"&gt;}}&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;proj_3 &lt;span class="o"&gt;{&lt;/span&gt;0.21355799999999994&lt;span class="o"&gt;}}&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;proj_4 &lt;span class="o"&gt;{&lt;/span&gt;0.46123659999999994&lt;span class="o"&gt;}}]}]}&lt;/span&gt;
Projects Result: &amp;amp;&lt;span class="o"&gt;{[{&lt;/span&gt;proj_1 Default project&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;proj_2 Assistant Test/Eval&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;proj_3 Kraken Production&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;proj_4 Knowledge Base&lt;span class="o"&gt;}]}&lt;/span&gt;
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  sanitizing names - formatting values with &lt;code&gt;strings.Replace()&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;But our names contain spaces and “/” symbols, and project names contain capital letters - and we want our metric labels to look like “&lt;em&gt;my_project_name&lt;/em&gt;”.&lt;/p&gt;

&lt;p&gt;Let’s add a function that will perform normalization using the &lt;a href="https://pkg.go.dev/strings#ToLower" rel="noopener noreferrer"&gt;&lt;code&gt;ToLower()&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://pkg.go.dev/strings#ReplaceAll" rel="noopener noreferrer"&gt;&lt;code&gt;ReplaceAll()&lt;/code&gt;&lt;/a&gt; methods from the &lt;a href="https://pkg.go.dev/strings" rel="noopener noreferrer"&gt;&lt;code&gt;strings&lt;/code&gt;&lt;/a&gt; package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;normalizeLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToLower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReplaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;" "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"_"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReplaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"_"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The next step is to build a map in which we will have &lt;code&gt;project_id&lt;/code&gt; and &lt;code&gt;project_names&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="n"&gt;projectNames&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="c"&gt;// get each 'ProjectsResponse.Data[].ID'&lt;/span&gt;
  &lt;span class="c"&gt;// get each 'ProjectsResponse.Data[].Name'&lt;/span&gt;
  &lt;span class="c"&gt;// populate the projectNames map with:&lt;/span&gt;
  &lt;span class="c"&gt;// 'project_id' = 'project_name'&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;projectsRes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Data&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;projectNames&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;normalizeLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Projects Names: "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;projectNames&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As a result, we have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;go run main.go
Projects Names: map[proj_1:kraken_production proj_2:assistant_test_eval proj_3:knowledge_base proj_4:default_project]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now let’s update our two loops - let’s use names instead of IDs in the label:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="c"&gt;// catch each item from the 'Response.Data[]'&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dataItem&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;costsRes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Data&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// catch each item from the 'Response.Data[].Results[]'&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;dataItem&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Results&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

      &lt;span class="c"&gt;// get ''Response.Data[].Results[].ProjectID'&lt;/span&gt;
      &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProjectID&lt;/span&gt;

      &lt;span class="c"&gt;// get ''Response.Data[].Results[].Amount.Value'&lt;/span&gt;
      &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;

      &lt;span class="c"&gt;// use the 'id' to get the project name from the projectNames map&lt;/span&gt;
      &lt;span class="n"&gt;project&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;projectNames&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;project&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"unknown"&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="c"&gt;// print in VictoriaMetrics gauge format&lt;/span&gt;
      &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"openai_stats{type=&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;costs&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;, project=&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;%s&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;} %f&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;go run main.go
openai_stats&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"costs"&lt;/span&gt;, &lt;span class="nv"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"knowledge_base"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; 2.170784
openai_stats&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"costs"&lt;/span&gt;, &lt;span class="nv"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"kraken_production"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; 0.241411
openai_stats&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"costs"&lt;/span&gt;, &lt;span class="nv"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"assistant_test_eval"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; 1.083077
openai_stats&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"costs"&lt;/span&gt;, &lt;span class="nv"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"default_project"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; 0.461237
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now we have the following code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s"&gt;"fmt"&lt;/span&gt;
  &lt;span class="s"&gt;"os"&lt;/span&gt;
  &lt;span class="s"&gt;"strconv"&lt;/span&gt;
  &lt;span class="s"&gt;"strings"&lt;/span&gt;
  &lt;span class="s"&gt;"time"&lt;/span&gt;

  &lt;span class="s"&gt;"github.com/go-resty/resty/v2"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;// set global const as ay be used in other packages&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;baseURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://api.openai.com/v1"&lt;/span&gt;
  &lt;span class="n"&gt;costsPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/organization/costs"&lt;/span&gt;
  &lt;span class="n"&gt;projectsPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/organization/projects"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;// catch data[] and pass to nested struct&lt;/span&gt;
&lt;span class="c"&gt;// catch results[] and pass to next nested struct&lt;/span&gt;
&lt;span class="c"&gt;// catch 'project_id' property to the 'ProjectID' field, and pass to next nested struct&lt;/span&gt;
&lt;span class="c"&gt;// catch 'amount' property to the 'Amount' field, and pass to next nested struct&lt;/span&gt;
&lt;span class="c"&gt;// finally, catch 'value' property to the 'Value' field&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;CostsResponseData&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;Data&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Results&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;ProjectID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"project_id"`&lt;/span&gt;
      &lt;span class="n"&gt;Amount&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Value&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;ProjectsResponse&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;Data&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;getOpenAi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;resty&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;R&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;SetResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;normalizeLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToLower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReplaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;" "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"_"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReplaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"_"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c"&gt;//client := resty.New()&lt;/span&gt;

  &lt;span class="n"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"OPENAI_ADMIN_KEY"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;timeNow&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strconv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FormatInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unix&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;setQueryParams&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s"&gt;"start_time"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timeNow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"group_by"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"project_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;resty&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;SetAuthToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;SetQueryParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;setQueryParams&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="c"&gt;// use pointer to ResponseData struct&lt;/span&gt;
  &lt;span class="c"&gt;// as 'json.Unmarshal' requires a pointer to write results&lt;/span&gt;
  &lt;span class="n"&gt;costsRes&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;CostsResponseData&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;getOpenAi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;baseURL&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;costsPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;costsRes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;panic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;projectsRes&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;ProjectsResponse&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;getOpenAi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;baseURL&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;projectsPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;projectsRes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;panic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;projectNames&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="c"&gt;// get each 'ProjectsResponse.Data[].ID'&lt;/span&gt;
  &lt;span class="c"&gt;// get each 'ProjectsResponse.Data[].Name'&lt;/span&gt;
  &lt;span class="c"&gt;// populate the projectNames map with:&lt;/span&gt;
  &lt;span class="c"&gt;// 'project_id' = 'project_name'&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;projectsRes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Data&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;projectNames&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;normalizeLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c"&gt;// catch each item from the 'Response.Data[]'&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dataItem&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;costsRes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Data&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// catch each item from the 'Response.Data[].Results[]'&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;dataItem&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Results&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

      &lt;span class="c"&gt;// get ''Response.Data[].Results[].ProjectID'&lt;/span&gt;
      &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProjectID&lt;/span&gt;

      &lt;span class="c"&gt;// get ''Response.Data[].Results[].Amount.Value'&lt;/span&gt;
      &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;

      &lt;span class="c"&gt;// use the 'id' to get the project name from the projectNames map&lt;/span&gt;
      &lt;span class="n"&gt;project&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;projectNames&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;project&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"unknown"&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="c"&gt;// print in VictoriaMetrics gauge format&lt;/span&gt;
      &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"openai_stats{type=&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;costs&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;, project=&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;%s&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;} %f&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now we can move on to creating real metrics and recording them in VictoriaMetrics.&lt;/p&gt;

&lt;h3&gt;
  
  
  Planning metrics for VictoriaMetrics
&lt;/h3&gt;

&lt;p&gt;So, our metrics will be in the form of &lt;code&gt;openai_stats{type="costs", project="prodject_id"} 5.55&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And what does the problem say you need to do as a result?&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;if daily spending on OpenAI exceeds the average for the last few days (with a certain threshold) - shout in Slack&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So, we will need the daily amount, and once we have it, we can make comparisons with previous periods.&lt;/p&gt;

&lt;p&gt;And what do we get back in the API?&lt;/p&gt;

&lt;p&gt;Let’s check the &lt;a href="https://platform.openai.com/docs/api-reference/usage/costs_object" rel="noopener noreferrer"&gt;Costs object&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;The aggregated costs details of the specific time bucket.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And what do we get when we make a request only with⁣ &lt;code&gt;start_time&lt;/code&gt; without &lt;code&gt;end_time&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;Let’s examine the time in the response received:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"start_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1762905600&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"end_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1762992000&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, the &lt;code&gt;start_time&lt;/code&gt; will be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; @1762905600
Wed Nov 12 02:00:00 EET 2025
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;А &lt;code&gt;end_time&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; @1762992000
Thu Nov 13 02:00:00 EET 2025
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is 00:00 UTC.&lt;/p&gt;

&lt;p&gt;That is, it returns the amount spent for today, for the current day, because this text was written on the 12th Nov.&lt;/p&gt;

&lt;p&gt;So here it is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
      "start_time": 1762905600,
      "end_time": 1762992000,
      ...
            "value": 1.76643575,
            "currency": "usd"
          },
          "line_item": null,
          "project_id": "proj_1",
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We can see that today the project with ID “&lt;em&gt;proj_1&lt;/em&gt;” spent 1.76643575 bucks.&lt;/p&gt;

&lt;p&gt;Okay…&lt;/p&gt;

&lt;p&gt;How can we store this in metrics? Create a Counter type that constantly increases and update it every minute or hour?&lt;/p&gt;

&lt;p&gt;Then the time series (see &lt;a href="https://rtfm.co.ua/en/victoriametrics-churn-rate-high-cardinality-metrics-an-indexdb/#What_is_the_Time_Series" rel="noopener noreferrer"&gt;What is a Time Series?&lt;/a&gt;) for this metric will look something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;openai_stats{type="costs", project="prodject_id"}
  1762960223 1.76
  1762960237 1.80
  1762960249 1.95
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And then we can create a request for the alert, something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if 
avg_over_time(openai_stats{type="costs", project="prodject_id"}[1d)
&amp;gt; 
avg_over_time(openai_stats{type="costs", project="prodject_id"}[3d)
then send alert
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But there is an important caveat with a Counter: it resets its value if the exporter restarts, see &lt;a href="https://docs.victoriametrics.com/victoriametrics/keyconcepts/#counter" rel="noopener noreferrer"&gt;counter reset&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In addition, if we receive data starting at 00:00, then from the next day the value will start at 0.00 USD.&lt;/p&gt;

&lt;p&gt;This means the metric value can go both up and down, so we need a Gauge rather than a Counter.&lt;/p&gt;

&lt;h3&gt;
  
  
  VictoriaMetrics Go client
&lt;/h3&gt;

&lt;p&gt;There is a &lt;a href="https://github.com/prometheus/client_golang" rel="noopener noreferrer"&gt;Prometheus client library&lt;/a&gt; for Go, but for our purposes we’ll use the &lt;a href="https://github.com/VictoriaMetrics/metrics" rel="noopener noreferrer"&gt;VictoriaMetrics package&lt;/a&gt;, which also includes a &lt;code&gt;PushMetrics()&lt;/code&gt; function to send metrics directly to a VictoriaMetrics endpoint. without needing to have a dedicated scrape job.&lt;/p&gt;

&lt;h3&gt;
  
  
  Create metrics with &lt;code&gt;NewGauge()&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Let’s look at the documentation for &lt;a href="https://pkg.go.dev/github.com/VictoriaMetrics/metrics#example-Gauge" rel="noopener noreferrer"&gt;&lt;code&gt;type Gauge&lt;/code&gt;&lt;/a&gt;, where there is an example of creating a metric object.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://pkg.go.dev/github.com/VictoriaMetrics/metrics#NewGauge" rel="noopener noreferrer"&gt;&lt;code&gt;NewGauge()&lt;/code&gt;&lt;/a&gt; function takes two arguments: the name of the metric with labels and the function that updates the value for this metric, see &lt;a href="https://github.com/VictoriaMetrics/metrics/blob/master/gauge.go#L25" rel="noopener noreferrer"&gt;&lt;code&gt;gauge.go&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;NewGauge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Gauge&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;defaultSet&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewGauge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But if we want to set the value ourselves, instead of passing the second argument &lt;code&gt;f func()&lt;/code&gt;, we can simply pass &lt;code&gt;nil&lt;/code&gt; and then use the &lt;a href="https://pkg.go.dev/github.com/VictoriaMetrics/metrics#Gauge.Set" rel="noopener noreferrer"&gt;&lt;code&gt;Set()&lt;/code&gt;&lt;/a&gt; method.&lt;/p&gt;

&lt;p&gt;Let’s try how it works with &lt;code&gt;nil&lt;/code&gt; and &lt;code&gt;Set()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gore&amp;gt; :import "github.com/VictoriaMetrics/metrics"
gore&amp;gt; g := metrics.NewGauge(`test_gauge`, nil)
gore&amp;gt; g.Set(9.00)
gore&amp;gt; :import fmt
gore&amp;gt; fmt.Println(g.Get())
9
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Great.&lt;/p&gt;

&lt;p&gt;Now let’s think about how we’re going to do all this.&lt;/p&gt;

&lt;p&gt;We need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;create a new &lt;code&gt;metrics.NewGauge()&lt;/code&gt; for each metric&lt;/li&gt;
&lt;li&gt;then, once an minute or hour, receive data from the API&lt;/li&gt;
&lt;li&gt;for each metric, execute &lt;code&gt;Set()&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is, we generate metrics, each with its own label value &lt;code&gt;project&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;project_1&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewGauge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;openai_stats&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"costs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"prodject_1"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt; 
&lt;span class="n"&gt;project_2&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewGauge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;openai_stats&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"costs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"prodject_2"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt; 
&lt;span class="n"&gt;project_3&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewGauge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;openai_stats&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"costs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"prodject_3"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, for each &lt;em&gt;project_N&lt;/em&gt;, we execute &lt;code&gt;Set()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;We now have a loop that fills in &lt;code&gt;fmt.Printf("openai_stats{type=\"costs\", project=\"%s\"} %f\n", project, amount)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Let’s first add metric generation directly to it and see how it might look.&lt;/p&gt;

&lt;p&gt;To output to the console, use the &lt;a href="https://pkg.go.dev/github.com/VictoriaMetrics/metrics#WritePrometheus" rel="noopener noreferrer"&gt;&lt;code&gt;metrics.WritePrometheus()&lt;/code&gt;&lt;/a&gt; function, which writes in Prometheus format to the channel specified by the first argument.&lt;/p&gt;

&lt;p&gt;After the loops, add:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
      &lt;span class="c"&gt;// print in VictoriaMetrics gauge format&lt;/span&gt;
      &lt;span class="c"&gt;//fmt.Printf("openai_stats{type=\"costs\", project=\"%s\"} %f\n", project, amount)&lt;/span&gt;
      &lt;span class="n"&gt;metricName&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`test_openai_stats{type="costs", project="%s"}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;gauge&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewGauge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metricName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;gauge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WritePrometheus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Stdout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As a result, we have the following data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;go run main.go
test_openai_stats&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"costs"&lt;/span&gt;, &lt;span class="nv"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"assistant_test_eval"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; 4.9838991
test_openai_stats&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"costs"&lt;/span&gt;, &lt;span class="nv"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"default_project"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; 0.5281144000000001
test_openai_stats&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"costs"&lt;/span&gt;, &lt;span class="nv"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"knowledge_base"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; 2.17244425
test_openai_stats&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"costs"&lt;/span&gt;, &lt;span class="nv"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"kraken_production"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; 0.5510669499999999
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Great.&lt;/p&gt;

&lt;p&gt;Now let’s think about the whole logic of execution.&lt;/p&gt;

&lt;p&gt;What we have now:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;creation of &lt;code&gt;resty.Client&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;initialization of the structure &lt;code&gt;costsRes := &amp;amp;CostsResponseData{}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;call &lt;code&gt;getOpenAi()&lt;/code&gt; with arguments (&lt;code&gt;client, baseURL+costsPath, costsRes&lt;/code&gt;), where we fill in the data in the &lt;code&gt;CostsResponseData&lt;/code&gt; structure&lt;/li&gt;
&lt;li&gt;initialization of the &lt;code&gt;projectsRes := &amp;amp;ProjectsResponse{}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;call &lt;code&gt;getOpenAi()&lt;/code&gt; with arguments (&lt;code&gt;client, baseURL+projectsPath, projectsRes&lt;/code&gt;), where we fill in the data in the &lt;code&gt;ProjectsResponse&lt;/code&gt; structure&lt;/li&gt;
&lt;li&gt;initialization of the &lt;code&gt;projectNames&lt;/code&gt; map&lt;/li&gt;
&lt;li&gt;fill it in with the data &lt;code&gt;"project_id": "project_name"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;further loops, in which:&lt;/li&gt;
&lt;li&gt;get &lt;code&gt;project_id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;get &lt;code&gt;amount&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;by &lt;code&gt;project_id&lt;/code&gt; we get the &lt;code&gt;project&lt;/code&gt; name, write it to the variable project&lt;/li&gt;
&lt;li&gt;generate the name of the metric and label from the &lt;code&gt;project&lt;/code&gt; into the &lt;code&gt;metricName&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;with &lt;code&gt;metrics.NewGauge()&lt;/code&gt; generate a new metric&lt;/li&gt;
&lt;li&gt;with &lt;code&gt;gauge.Set(amount)&lt;/code&gt; write a value into it&lt;/li&gt;
&lt;li&gt;with &lt;code&gt;metrics.WritePrometheus()&lt;/code&gt;, all generated metrics are output to the console&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And all this is now executed when the &lt;code&gt;main()&lt;/code&gt; is called.&lt;/p&gt;

&lt;p&gt;Instead, when calling &lt;code&gt;main()&lt;/code&gt;, i.e., when starting the exporter, we need to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;create &lt;code&gt;resty.Client&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;then periodically update the data and write it to VictoriaMetrics:&lt;/li&gt;
&lt;li&gt;call &lt;code&gt;getOpenAi()&lt;/code&gt; to fill in the &lt;code&gt;ProjectsResponse&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;with &lt;code&gt;getOpenAi()&lt;/code&gt; fill in the structure &lt;code&gt;CostsResponseData&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;fill in &lt;code&gt;projectNames&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;run loops to generate metrics and execute &lt;code&gt;Set()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;at the end of the loops, execute &lt;code&gt;WritePrometheus()&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;However, with this approach, we will be rewriting the fields in &lt;code&gt;ProjectsResponse&lt;/code&gt;, &lt;code&gt;CostsResponseData&lt;/code&gt;, and &lt;code&gt;projectNames&lt;/code&gt; every hour, which is not very good from a performance point of view.&lt;br&gt;&lt;br&gt;
. But if we have a new project, we will immediately "catch" it and add a new metric for it.&lt;/p&gt;

&lt;p&gt;So, what we need to do is move our logic into a dedicated function, call it periodically, and then execute &lt;code&gt;WritePrometheus()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Write this function, just replace the &lt;code&gt;NewGauge()&lt;/code&gt; with &lt;code&gt;GetOrCreateGauge()&lt;/code&gt;, because the next time our function is called, the metrics will already have been created:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;fetchAndPush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;resty&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;costsRes&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;CostsResponseData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;projectsRes&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;ProjectsResponse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;projectNames&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;getOpenAi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;baseURL&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;costsPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;costsRes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;panic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;getOpenAi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;baseURL&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;projectsPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;projectsRes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;panic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c"&gt;// get each 'ProjectsResponse.Data[].ID'&lt;/span&gt;
  &lt;span class="c"&gt;// get each 'ProjectsResponse.Data[].Name'&lt;/span&gt;
  &lt;span class="c"&gt;// populate the projectNames map with:&lt;/span&gt;
  &lt;span class="c"&gt;// 'project_id' = 'project_name'&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;projectsRes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Data&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;projectNames&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;normalizeLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c"&gt;// catch each item from the 'Response.Data[]'&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dataItem&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;costsRes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Data&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// catch each item from the 'Response.Data[].Results[]'&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;dataItem&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Results&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

      &lt;span class="c"&gt;// get 'Response.Data[].Results[].ProjectID'&lt;/span&gt;
      &lt;span class="c"&gt;// i.e. 'proj_123'&lt;/span&gt;
      &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProjectID&lt;/span&gt;

      &lt;span class="c"&gt;// get 'Response.Data[].Results[].Amount.Value'&lt;/span&gt;
      &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;

      &lt;span class="c"&gt;// use the 'id' to get the project name from the projectNames map&lt;/span&gt;
      &lt;span class="n"&gt;project&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;projectNames&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;project&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"unknown"&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="c"&gt;// print in VictoriaMetrics gauge format&lt;/span&gt;
      &lt;span class="c"&gt;//fmt.Printf("openai_stats{type=\"costs\", project=\"%s\"} %f\n", project, amount)&lt;/span&gt;
      &lt;span class="n"&gt;metricName&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`test_openai_stats{type="costs", project="%s"}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;gauge&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetOrCreateGauge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metricName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;gauge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WritePrometheus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Stdout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now in the &lt;code&gt;main()&lt;/code&gt; we have only this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c"&gt;//client := resty.New()&lt;/span&gt;

  &lt;span class="n"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"OPENAI_ADMIN_KEY"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;timeNow&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strconv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FormatInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unix&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;setQueryParams&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s"&gt;"start_time"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timeNow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"group_by"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"project_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;resty&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;SetAuthToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;SetQueryParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;setQueryParams&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="c"&gt;// use pointer to ResponseData struct&lt;/span&gt;
  &lt;span class="c"&gt;// as 'json.Unmarshal' requires a pointer to write results&lt;/span&gt;
  &lt;span class="n"&gt;costsRes&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;CostsResponseData&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="n"&gt;projectsRes&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;ProjectsResponse&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="c"&gt;// will be populated with key:value pairs:&lt;/span&gt;
  &lt;span class="c"&gt;// 'proj_123' = 'kraken_production'&lt;/span&gt;
  &lt;span class="n"&gt;projectNames&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;fetchAndPush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;costsRes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;projectsRes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;projectNames&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let’s run it to check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;go run main.go
test_openai_stats&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"costs"&lt;/span&gt;, &lt;span class="nv"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"assistant_test_eval"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; 6.3417053
test_openai_stats&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"costs"&lt;/span&gt;, &lt;span class="nv"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"default_project"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; 0.6592560500000001
test_openai_stats&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"costs"&lt;/span&gt;, &lt;span class="nv"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"knowledge_base"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; 2.17244425
test_openai_stats&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"costs"&lt;/span&gt;, &lt;span class="nv"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"kraken_production"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; 0.6170747
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, instead of simply printing to the console, we need to write the data to VictoriaMetrics.&lt;/p&gt;

&lt;h3&gt;
  
  
  Recording metrics to VictoriaMetrics with &lt;code&gt;InitPush()&lt;/code&gt; and &lt;code&gt;PushMetrics()&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;To record metrics in VictoriaMetrics, we have two main functions: &lt;code&gt;InitPush()&lt;/code&gt; and &lt;code&gt;PushMetrics()&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Inside the &lt;code&gt;InitPush()&lt;/code&gt; implementation
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;InitPush()&lt;/code&gt; function allows you to perform periodic recordings with a specified &lt;code&gt;interval&lt;/code&gt;, while &lt;code&gt;PushMetrics()&lt;/code&gt; allows you to simply record all metrics that are in &lt;code&gt;Set struct&lt;/code&gt; once. More about &lt;code&gt;Set&lt;/code&gt; below.&lt;/p&gt;

&lt;p&gt;Now, just for the sake of interest, let’s take a look at how the VictoriaMetrics client performs recording.&lt;/p&gt;

&lt;p&gt;We find the code &lt;a href="https://github.com/VictoriaMetrics/metrics/blob/v1.40.2/push.go#L100" rel="noopener noreferrer"&gt;&lt;code&gt;InitPush()&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;InitPush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pushURL&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extraLabels&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pushProcessMetrics&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;writeMetrics&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Writer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;WritePrometheus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pushProcessMetrics&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;InitPushExt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pushURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extraLabels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;writeMetrics&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;in our code, we call &lt;code&gt;InitPush()&lt;/code&gt;, pass the URL and interval to this function&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;InitPush()&lt;/code&gt; creates a variable &lt;code&gt;writeMetrics&lt;/code&gt; - an anonymous function that takes an argument of type &lt;code&gt;io. Writer&lt;/code&gt; type, which will then call the &lt;code&gt;WritePrometheus()&lt;/code&gt; function, to which this &lt;code&gt;io.Writer&lt;/code&gt; is passed&lt;/li&gt;
&lt;li&gt;next, the &lt;code&gt;InitPushExt()&lt;/code&gt; function is called with the &lt;code&gt;pushURL&lt;/code&gt;, &lt;code&gt;interval&lt;/code&gt;, and the &lt;code&gt;writeMetrics&lt;/code&gt; object as arguments&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let’s look at &lt;a href="https://github.com/VictoriaMetrics/metrics/blob/v1.40.2/push.go#L184" rel="noopener noreferrer"&gt;&lt;code&gt;InitPushExt()&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;InitPushExt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pushURL&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extraLabels&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;writeMetrics&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Writer&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;opts&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;PushOptions&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ExtraLabels&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;extraLabels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;InitPushExtWithOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;pushURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;writeMetrics&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, parameters are simply added from the &lt;a href="https://github.com/VictoriaMetrics/metrics/blob/v1.40.2/push.go#L21" rel="noopener noreferrer"&gt;&lt;code&gt;PushOptions&lt;/code&gt;&lt;/a&gt;, to which we can pass parameters of type &lt;code&gt;extraLabels&lt;/code&gt;, and then &lt;code&gt;InitPushExtWithOptions()&lt;/code&gt; is called, to which our &lt;code&gt;writeMetrics&lt;/code&gt; is passed.&lt;/p&gt;

&lt;p&gt;Let’s look at &lt;a href="https://github.com/VictoriaMetrics/metrics/blob/v1.40.2/push.go#L209" rel="noopener noreferrer"&gt;&lt;code&gt;InitPushExtWithOptions()&lt;/code&gt;&lt;/a&gt;: here, a goroutine is created that calls &lt;code&gt;pushMetrics()&lt;/code&gt; with a specified &lt;code&gt;interval&lt;/code&gt;, to which our &lt;code&gt;writeMetrics&lt;/code&gt; object is passed (i.e., the anonymous function that will call the &lt;code&gt;WritePrometheus()&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;InitPushExtWithOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pushURL&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;writeMetrics&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Writer&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;opts&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;PushOptions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;newPushContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pushURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ticker&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewTicker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;...&lt;/span&gt;
        &lt;span class="n"&gt;ctxLocal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pushMetrics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctxLocal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;writeMetrics&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, &lt;code&gt;pushMetrics()&lt;/code&gt; creates a buffer &lt;code&gt;bytes.Buffer&lt;/code&gt;, passes it to the &lt;code&gt;writeMetrics()&lt;/code&gt;, and &lt;code&gt;writeMetrics()&lt;/code&gt; calls &lt;code&gt;WritePrometheus()&lt;/code&gt;, which receives this buffer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pc&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;pushContext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;pushMetrics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;writeMetrics&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Writer&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;bb&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;getBytesBuffer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;putBytesBuffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;writeMetrics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And then &lt;a href="https://github.com/VictoriaMetrics/metrics/blob/v1.40.2/metrics.go#L94" rel="noopener noreferrer"&gt;&lt;code&gt;WritePrometheus()&lt;/code&gt;&lt;/a&gt; writes the collected metrics to this buffer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// WritePrometheus writes all the metrics from s to w in Prometheus format.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;WritePrometheus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Writer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, from this buffer (still in &lt;code&gt;pushMetrics()&lt;/code&gt;), a request body is created and headers are set:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw7knitdw6gjxjs2ibmfr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw7knitdw6gjxjs2ibmfr.png" width="798" height="131"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And then the data is sent to the specified URL:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw5dblr7xs2znvo6gr3hw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw5dblr7xs2znvo6gr3hw.png" width="799" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  VictoriaMetrics and the Set struct
&lt;/h3&gt;

&lt;p&gt;Now let’s return to “&lt;em&gt;WritePrometheus() writes the collected metrics to this buffer&lt;/em&gt;”.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;WritePrometheus()&lt;/code&gt; is a method of the &lt;a href="https://pkg.go.dev/github.com/VictoriaMetrics/metrics#Set" rel="noopener noreferrer"&gt;&lt;code&gt;Set&lt;/code&gt;&lt;/a&gt; structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;WritePrometheus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Writer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;Set&lt;/code&gt; is created when we call &lt;code&gt;NetGauge()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;NewGauge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Gauge&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;defaultSet&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewGauge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;defaultSet&lt;/code&gt; is a call to &lt;a href="https://github.com/VictoriaMetrics/metrics/blob/v1.40.2/set.go#L29" rel="noopener noreferrer"&gt;&lt;code&gt;NewSet()&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;where&lt;/span&gt; &lt;span class="n"&gt;defaultSet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NewSet&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;NewSet()&lt;/code&gt; fills the &lt;code&gt;Set&lt;/code&gt; structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// NewSet creates new set of metrics.&lt;/span&gt;
&lt;span class="c"&gt;//&lt;/span&gt;
&lt;span class="c"&gt;// Pass the set to RegisterSet() function in order to export its metrics via global WritePrometheus() call.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;NewSet&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;namedMetric&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is, when calling &lt;code&gt;NetGauge()&lt;/code&gt;, we pass an argument with the name of the metric, &lt;code&gt;NetGauge()&lt;/code&gt; calls &lt;code&gt;NewSet()&lt;/code&gt;, passes this metric, and &lt;code&gt;NewSet()&lt;/code&gt; initializes the &lt;code&gt;Set&lt;/code&gt; structure, setting our metric in the &lt;code&gt;namedMetric&lt;/code&gt; field.&lt;/p&gt;

&lt;h3&gt;
  
  
  Inside the &lt;code&gt;PushMetrics()&lt;/code&gt; function implementation
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;PushMetrics()&lt;/code&gt;, the flow is almost the same - &lt;code&gt;writeMetrics&lt;/code&gt; is created, &lt;code&gt;PushMetricsExt()&lt;/code&gt; is called:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;PushMetrics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pushURL&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pushProcessMetrics&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;opts&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;PushOptions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;writeMetrics&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Writer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;WritePrometheus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pushProcessMetrics&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;PushMetricsExt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pushURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;writeMetrics&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;PushMetricsExt()&lt;/code&gt; calls &lt;code&gt;pushMetrics()&lt;/code&gt;, but only once, not in a loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;PushMetricsExt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pushURL&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;writeMetrics&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Writer&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;opts&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;PushOptions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;newPushContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pushURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pushMetrics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;writeMetrics&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Okay, let’s return to our code.&lt;/p&gt;

&lt;p&gt;So, what we need to do now is call &lt;code&gt;PushMetrics()&lt;/code&gt; instead of &lt;code&gt;WritePrometheus()&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating &lt;code&gt;context&lt;/code&gt; and calling &lt;code&gt;PushMetrics()&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;For &lt;code&gt;PushMetrics()&lt;/code&gt;, we need to pass a context that manages goroutines and terminates them either on a timeout or if the program itself receives &lt;code&gt;SIGTERM&lt;/code&gt; or &lt;code&gt;SIGKILL&lt;/code&gt; signals from the system.&lt;/p&gt;

&lt;p&gt;More details about &lt;a href="https://pkg.go.dev/context" rel="noopener noreferrer"&gt;context&lt;/a&gt; will follow below, but for now, let’s just add &lt;code&gt;import "context"&lt;/code&gt; and create an empty context with &lt;a href="https://pkg.go.dev/context#Background" rel="noopener noreferrer"&gt;&lt;code&gt;Background()&lt;/code&gt;&lt;/a&gt; in &lt;code&gt;main()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s"&gt;"context"&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="c"&gt;// will be populated with key:value pairs:&lt;/span&gt;
  &lt;span class="c"&gt;// 'proj_123' = 'kraken_production'&lt;/span&gt;
  &lt;span class="n"&gt;projectNames&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In our function &lt;code&gt;fetchAndPush()&lt;/code&gt;, add a parameter with type &lt;code&gt;context.Context&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;fetchAndPush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
   &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add context passing to the &lt;code&gt;fetchAndPush()&lt;/code&gt; call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
       &lt;span class="n"&gt;fetchAndPush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;costsRes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;projectsRes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;projectNames&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set a variable with the VictoriaMetrics instance URL, change &lt;code&gt;metrics.WritePrometheus()&lt;/code&gt; to &lt;code&gt;metrics.PushMetrics()&lt;/code&gt;, to which we pass the context received from &lt;code&gt;main()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;

  &lt;span class="c"&gt;//metrics.WritePrometheus(os.Stdout, false)&lt;/span&gt;
  &lt;span class="n"&gt;pushURL&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s"&gt;"http://localhost:8428/api/v1/import/prometheus"&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PushMetrics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pushURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;panic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the &lt;code&gt;pushURL&lt;/code&gt;, I'm using localhost where have &lt;code&gt;kubectl port-forward&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="err"&gt;$&lt;/span&gt; &lt;span class="n"&gt;kubectl&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="n"&gt;ops&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;monitoring&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;ns&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;forward&lt;/span&gt; &lt;span class="n"&gt;svc&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;vmsingle&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;k8s&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;stack&lt;/span&gt; &lt;span class="m"&gt;8428&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When we launch the exporter in Kubernetes, we will add a new environment variable passed from a Helm chart values.&lt;/p&gt;

&lt;p&gt;At this point, the main thing left to do is to run our function on a schedule.&lt;/p&gt;

&lt;h3&gt;
  
  
  Using &lt;code&gt;gocron&lt;/code&gt; for tasks scheduling
&lt;/h3&gt;

&lt;p&gt;There is a nice package called &lt;a href="https://github.com/go-co-op/gocron" rel="noopener noreferrer"&gt;&lt;code&gt;gocron&lt;/code&gt;&lt;/a&gt;. Let's add it and set it to run our &lt;code&gt;fetchAndPush()&lt;/code&gt; function every minute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;import &lt;span class="o"&gt;(&lt;/span&gt;
        ...
  &lt;span class="s2"&gt;"github.com/go-co-op/gocron"&lt;/span&gt;
        ...
&lt;span class="o"&gt;)&lt;/span&gt;

...
func main&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  s :&lt;span class="o"&gt;=&lt;/span&gt; gocron.NewScheduler&lt;span class="o"&gt;(&lt;/span&gt;time.Local&lt;span class="o"&gt;)&lt;/span&gt;

  s.Every&lt;span class="o"&gt;(&lt;/span&gt;1&lt;span class="o"&gt;)&lt;/span&gt;.Minute&lt;span class="o"&gt;()&lt;/span&gt;.Do&lt;span class="o"&gt;(&lt;/span&gt;func&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    fetchAndPush&lt;span class="o"&gt;(&lt;/span&gt;client, costsRes, projectsRes, projectNames&lt;span class="o"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;})&lt;/span&gt;

  s.StartBlocking&lt;span class="o"&gt;()&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then we can change it to call once an hour - &lt;code&gt;s.Every(1).Hour().Do( ... )&lt;/code&gt;, or at the beginning of each hour - &lt;code&gt;s.Cron("0 * * * *").Do( ... )&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And finally, launch the cron with &lt;code&gt;StartBlocking()&lt;/code&gt;, which blocks the finishing of the &lt;code&gt;main()&lt;/code&gt; function itself.&lt;/p&gt;

&lt;p&gt;Open access to VictoriaMetrics in Kubernetes, if not done yet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="err"&gt;$&lt;/span&gt; &lt;span class="n"&gt;kk&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="n"&gt;ops&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;monitoring&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;ns&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;forward&lt;/span&gt; &lt;span class="n"&gt;svc&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;vmsingle&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;k8s&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;stack&lt;/span&gt; &lt;span class="m"&gt;8428&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let’s launch our exporter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;go run main.go
test_openai_stats&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"costs"&lt;/span&gt;, &lt;span class="nv"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"assistant_test_eval"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; 6.501765299999999
test_openai_stats&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"costs"&lt;/span&gt;, &lt;span class="nv"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"default_project"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; 0.6592560500000001
test_openai_stats&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"costs"&lt;/span&gt;, &lt;span class="nv"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"knowledge_base"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; 2.17411225
test_openai_stats&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"costs"&lt;/span&gt;, &lt;span class="nv"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"kraken_production"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; 0.6471627999999999
^Csignal: interrupt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And check the data in VictoriaMetrics:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Felj9zasg7w9ctik9mxl4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Felj9zasg7w9ctik9mxl4.png" width="766" height="1185"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;However, some “&lt;em&gt;unknown&lt;/em&gt;” project has appeared here, so logging will need to be added.&lt;/p&gt;

&lt;p&gt;What else needs to be fixed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;currently, the initialization of the &lt;code&gt;CostsResponseData&lt;/code&gt; and &lt;code&gt;ProjectsResponse&lt;/code&gt; structures is performed in &lt;code&gt;main()&lt;/code&gt;, and then data is written to them each time &lt;code&gt;fetchAndPush()&lt;/code&gt; is called&lt;/li&gt;
&lt;li&gt;if a project is deleted from OpenAI, it will remain in the structures, and we will continue to write metrics for a project that no longer exists&lt;/li&gt;
&lt;li&gt;thus, this needs to be moved to the &lt;code&gt;fetchAndPush()&lt;/code&gt; and simply filled in from scratch each time&lt;/li&gt;
&lt;li&gt;the same for the &lt;code&gt;projectNames&lt;/code&gt; - move initialization to &lt;code&gt;fetchAndPush()&lt;/code&gt; itself&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SetQueryParams&lt;/code&gt; - currently passed identically for both &lt;code&gt;getOpenAi()&lt;/code&gt; calls, but there is no &lt;code&gt;group_by&lt;/code&gt; parameter for the &lt;code&gt;/organization/projects&lt;/code&gt; OpenAI endpoint&lt;/li&gt;
&lt;li&gt;in the label metric, it is better to replace &lt;code&gt;type=""&lt;/code&gt; with &lt;code&gt;category=""&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;need to add external labels - something like “&lt;code&gt;job="openai-exporter"&lt;/code&gt;"&lt;/li&gt;
&lt;li&gt;instead of using &lt;code&gt;panic(err)&lt;/code&gt;, return an error to the calling function, handle it there, and log messages&lt;/li&gt;
&lt;li&gt;add correct handling of &lt;code&gt;SIGTERM&lt;/code&gt; and &lt;code&gt;SIGINT&lt;/code&gt; signals&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;resty.client&lt;/code&gt; can perform retries in case of errors, so we can add &lt;code&gt;SetRetryCount()&lt;/code&gt; and &lt;code&gt;SetRetryWaitTime()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;and add execution and error logs&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Creating a Golang context
&lt;/h3&gt;

&lt;p&gt;While working in our code, several simultaneous operations are launched - with &lt;code&gt;gocron.NewScheduler()&lt;/code&gt; we launch the execution of our function &lt;code&gt;fetchAndPush()&lt;/code&gt; which makes HTTP requests with &lt;code&gt;resty.Client.Get()&lt;/code&gt;, and VictoriaMetrics launches its operations to write to the VictoriaMetrics endpoint.&lt;/p&gt;

&lt;p&gt;To shut the application down gracefully instead of simply “killing” it when receiving &lt;code&gt;SIGINT&lt;/code&gt; or &lt;code&gt;SIGTERM&lt;/code&gt;, Go allows us to control the termination process of our functions and goroutines through the &lt;em&gt;context&lt;/em&gt; of execution.&lt;/p&gt;

&lt;p&gt;Another example of when we need to control the execution of an operation is to set a time limit for execution, as it is done, for example, in the VictoriaMetrics Go client for the &lt;code&gt;InitPushExtWithOptions()&lt;/code&gt; function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;...
  go func&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    ticker :&lt;span class="o"&gt;=&lt;/span&gt; time.NewTicker&lt;span class="o"&gt;(&lt;/span&gt;interval&lt;span class="o"&gt;)&lt;/span&gt;
    defer ticker.Stop&lt;span class="o"&gt;()&lt;/span&gt;
    stopCh :&lt;span class="o"&gt;=&lt;/span&gt; ctx.Done&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &amp;lt;&lt;span class="nt"&gt;-ticker&lt;/span&gt;.C:
        ctxLocal, cancel :&lt;span class="o"&gt;=&lt;/span&gt; context.WithTimeout&lt;span class="o"&gt;(&lt;/span&gt;ctx, interval+time.Second&lt;span class="p"&gt;)&lt;/span&gt;
        err :&lt;span class="o"&gt;=&lt;/span&gt; pc.pushMetrics&lt;span class="o"&gt;(&lt;/span&gt;ctxLocal, writeMetrics&lt;span class="o"&gt;)&lt;/span&gt;
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, the execution of &lt;code&gt;pc.pushMetrics()&lt;/code&gt; is limited by &lt;code&gt;interval&lt;/code&gt;, which is passed when calling &lt;code&gt;InitPush()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;At the same time, the execution context includes not only signal handling and lifecycle management of functions and goroutines, but also other information related to this execution:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Package context defines the Context type, which carries deadlines, cancellation signals,&lt;/em&gt; &lt;strong&gt;&lt;em&gt;and other request-scoped values&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;across API boundaries and between processes&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I moved the details about context to a dedicated section - &lt;a href="https://rtfm.co.ua/en/?p=33440#Bonus_how_execution_control_works_through_Golang_context" rel="noopener noreferrer"&gt;Bonus: How execution control works with Go context&lt;/a&gt; below - because it is a very interesting mechanism, and now let’s just add it to our code.&lt;/p&gt;

&lt;p&gt;So, what do we need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;create a context&lt;/li&gt;
&lt;li&gt;create a “signal interceptor” &lt;code&gt;SIGINT&lt;/code&gt; (Ctrl+C) and &lt;code&gt;SIGTERM&lt;/code&gt; (a signal from the operating system when the program execution ends, for example, when kubelet stops the container)&lt;/li&gt;
&lt;li&gt;send a stop signal to all child functions and goroutines&lt;/li&gt;
&lt;li&gt;complete execution of &lt;code&gt;main()&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To do this, instead of calling &lt;code&gt;context.Background()&lt;/code&gt; in &lt;code&gt;main()&lt;/code&gt;, we can use &lt;a href="https://pkg.go.dev/os/signal#NotifyContext" rel="noopener noreferrer"&gt;&lt;code&gt;signal.NotifyContext()&lt;/code&gt;&lt;/a&gt; which receives the necessary system calls and sends a stop signal to all related tasks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="n"&gt;rootCtx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rootCancel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NotifyContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Interrupt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SIGTERM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;rootCancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, we have the call of the &lt;code&gt;gocron.NewScheduler()&lt;/code&gt;, and at the end of the &lt;code&gt;main()&lt;/code&gt; we launch the creation and reading from the channel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="c"&gt;// block until Ctrl+C cancels rootCtx&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;rootCtx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Done&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As soon as &lt;code&gt;NotifyContext()&lt;/code&gt; receives &lt;code&gt;SIGTERM&lt;/code&gt;, it closes the channel &lt;code&gt;rootCtx.Done()&lt;/code&gt; channel, after which all child context channels will be closed in cascade, then all child goroutines listening to these contexts will terminate, and &lt;code&gt;main()&lt;/code&gt; will be able to terminate correctly.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;resty.client&lt;/code&gt; can also work with context via &lt;a href="https://pkg.go.dev/github.com/go-resty/resty/v2#Request.SetContext" rel="noopener noreferrer"&gt;&lt;code&gt;SetContext()&lt;/code&gt;&lt;/a&gt; to which we pass our &lt;code&gt;rootCtx&lt;/code&gt; when calling &lt;code&gt;if err := getOpenAI(ctx, ... ) {...}&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The final result of the OpenAI Exporter code
&lt;/h3&gt;

&lt;p&gt;After all the edits, the entire exporter code now looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
 &lt;span class="s"&gt;"context"&lt;/span&gt;
 &lt;span class="s"&gt;"fmt"&lt;/span&gt;
 &lt;span class="s"&gt;"log"&lt;/span&gt;
 &lt;span class="s"&gt;"os"&lt;/span&gt;
 &lt;span class="s"&gt;"os/signal"&lt;/span&gt;
 &lt;span class="s"&gt;"strconv"&lt;/span&gt;
 &lt;span class="s"&gt;"strings"&lt;/span&gt;
 &lt;span class="s"&gt;"syscall"&lt;/span&gt;
 &lt;span class="s"&gt;"time"&lt;/span&gt;

 &lt;span class="s"&gt;"github.com/VictoriaMetrics/metrics"&lt;/span&gt;
 &lt;span class="s"&gt;"github.com/go-co-op/gocron"&lt;/span&gt;
 &lt;span class="s"&gt;"github.com/go-resty/resty/v2"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
 &lt;span class="c"&gt;// base URL of the OpenAI Admin API&lt;/span&gt;
 &lt;span class="n"&gt;baseURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://api.openai.com/v1"&lt;/span&gt;

 &lt;span class="c"&gt;// endpoints that we call&lt;/span&gt;
 &lt;span class="n"&gt;costsPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/organization/costs"&lt;/span&gt;
 &lt;span class="n"&gt;projectsPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/organization/projects"&lt;/span&gt;

 &lt;span class="c"&gt;// VictoriaMetrics push endpoint (Prometheus remote write format)&lt;/span&gt;
 &lt;span class="c"&gt;//pushURL = "http://localhost:8428/api/v1/import/prometheus"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;// structure describing the JSON for costs API&lt;/span&gt;
&lt;span class="c"&gt;// resty will unmarshal into this struct automatically&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;CostsResponseData&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
 &lt;span class="n"&gt;Data&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;Results&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
   &lt;span class="n"&gt;ProjectID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"project_id"`&lt;/span&gt;
   &lt;span class="n"&gt;Amount&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Value&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt; &lt;span class="s"&gt;`json:"value"`&lt;/span&gt;
   &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="s"&gt;`json:"amount"`&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="s"&gt;`json:"results"`&lt;/span&gt;
 &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="s"&gt;`json:"data"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// structure describing the JSON for projects API&lt;/span&gt;
&lt;span class="c"&gt;// used to map project_id → readable project name&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;ProjectsResponse&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
 &lt;span class="n"&gt;Data&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;ID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"id"`&lt;/span&gt;
  &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"name"`&lt;/span&gt;
 &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="s"&gt;`json:"data"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// normalizeLabel converts a project name into a Prometheus-safe label&lt;/span&gt;
&lt;span class="c"&gt;// - lowercases&lt;/span&gt;
&lt;span class="c"&gt;// - replaces spaces with underscores&lt;/span&gt;
&lt;span class="c"&gt;// - replaces slashes to avoid label parser issues&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;normalizeLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
 &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToLower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReplaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;" "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"_"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReplaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"_"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

 &lt;span class="c"&gt;// set optional query parameters&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetQueryParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="p"&gt;}&lt;/span&gt;

 &lt;span class="c"&gt;// perform request&lt;/span&gt;
 &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;baseURL&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"http transport error calling %s: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="p"&gt;}&lt;/span&gt;

 &lt;span class="c"&gt;// check HTTP status codes&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsSuccess&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
   &lt;span class="s"&gt;"OpenAI API error: path=%s status=%d body=%s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
   &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
   &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
   &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="p"&gt;}&lt;/span&gt;

 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

 &lt;span class="c"&gt;// build query parameters for costs API&lt;/span&gt;
 &lt;span class="c"&gt;// start_time: current timestamp (Unix)&lt;/span&gt;
 &lt;span class="c"&gt;// group_by: instruct API to group costs per project_id&lt;/span&gt;
 &lt;span class="n"&gt;timeNow&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strconv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FormatInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unix&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="n"&gt;costParams&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="s"&gt;"start_time"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timeNow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s"&gt;"group_by"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"project_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="p"&gt;}&lt;/span&gt;

 &lt;span class="c"&gt;// fetch costs data&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;getOpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;costsPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;costParams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;costsRes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"fetch costs: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="p"&gt;}&lt;/span&gt;

 &lt;span class="c"&gt;// fetch project definitions&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;getOpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;projectsPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;projectsRes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"fetch projects: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="p"&gt;}&lt;/span&gt;

 &lt;span class="c"&gt;// fill map: project_id → normalized_label&lt;/span&gt;
 &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;projectsRes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Data&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;projectNames&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;normalizeLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="p"&gt;}&lt;/span&gt;

 &lt;span class="c"&gt;// process returned costs&lt;/span&gt;
 &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dataItem&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;costsRes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Data&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;dataItem&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Results&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
   &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProjectID&lt;/span&gt;
   &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;

   &lt;span class="c"&gt;// resolve project readable name&lt;/span&gt;
   &lt;span class="n"&gt;project&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;projectNames&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
   &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;project&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"unknown"&lt;/span&gt;
   &lt;span class="p"&gt;}&lt;/span&gt;

   &lt;span class="n"&gt;metricName&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;`openai_stats{project="%s",category="costs"}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
   &lt;span class="p"&gt;)&lt;/span&gt;

   &lt;span class="c"&gt;// get or create gauge&lt;/span&gt;
   &lt;span class="n"&gt;gauge&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetOrCreateGauge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metricName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

   &lt;span class="c"&gt;// update gauge value&lt;/span&gt;
   &lt;span class="n"&gt;gauge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

   &lt;span class="c"&gt;// log written metric&lt;/span&gt;
   &lt;span class="c"&gt;// explanation: this log helps to debug what exactly was pushed&lt;/span&gt;
   &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"metric updated: name=%s value=%f"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metricName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
 &lt;span class="p"&gt;}&lt;/span&gt;

 &lt;span class="c"&gt;// push metrics with job="openai_exporter"&lt;/span&gt;
 &lt;span class="n"&gt;pushOpts&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PushOptions&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;ExtraLabels&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;`job="openai_exporter"`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="p"&gt;}&lt;/span&gt;

 &lt;span class="c"&gt;// push all collected metrics&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PushMetrics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vmUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pushOpts&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"push metrics: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="p"&gt;}&lt;/span&gt;

 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

 &lt;span class="c"&gt;// load OpenAI admin API key&lt;/span&gt;
 &lt;span class="n"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"OPENAI_ADMIN_KEY"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"OPENAI_ADMIN_KEY is not set"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="p"&gt;}&lt;/span&gt;

 &lt;span class="c"&gt;// load VictoriaMetrics URL admin API key&lt;/span&gt;
 &lt;span class="n"&gt;vmUrl&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"VM_URL"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;vmUrl&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"VM_URL is not set"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="p"&gt;}&lt;/span&gt;

 &lt;span class="c"&gt;// create resty client with:&lt;/span&gt;
 &lt;span class="c"&gt;// - bearer token&lt;/span&gt;
 &lt;span class="c"&gt;// - automatic retries (3 attempts)&lt;/span&gt;
 &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;resty&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
  &lt;span class="n"&gt;SetAuthToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
  &lt;span class="n"&gt;SetRetryCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
  &lt;span class="n"&gt;SetRetryWaitTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

 &lt;span class="c"&gt;// create scheduler using local timezone&lt;/span&gt;
 &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;gocron&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewScheduler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Local&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

 &lt;span class="c"&gt;// register a job that runs every 1 minute&lt;/span&gt;
 &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Every&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Minute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Do&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"starting fetch-and-push cycle"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="c"&gt;// run our exporter cycle&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fetchAndPush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rootCtx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vmUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
   &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ERROR during fetchAndPush:"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"fetch-and-push completed in"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Since&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
 &lt;span class="p"&gt;})&lt;/span&gt;

 &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"starting scheduler..."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

 &lt;span class="c"&gt;// run scheduler in background goroutine&lt;/span&gt;
 &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StartAsync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

 &lt;span class="c"&gt;// block until Ctrl+C cancels rootCtx&lt;/span&gt;
 &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;rootCtx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Done&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

 &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"received Ctrl+C, stopping scheduler..."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

 &lt;span class="c"&gt;// shutdown scheduler gracefully&lt;/span&gt;
 &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

 &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"scheduler stopped, exiting"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let’s check it out on VictoriaMetrics:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fod14e504ljo4at3fi71e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fod14e504ljo4at3fi71e.png" width="722" height="1112"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And let’s compare this with the data on OpenAI’s own website at &lt;a href="https://platform.openai.com/settings/organization/usage" rel="noopener noreferrer"&gt;platform.openai.com/settings/organization/usage&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6zujnjalyfcghra5jcsc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6zujnjalyfcghra5jcsc.png" width="799" height="224"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same $6.95 that we see in VictoriaMetrics from our exporter.&lt;/p&gt;

&lt;p&gt;The code could be improved further, for example, by breaking down the large function &lt;code&gt;fetchAndPush()&lt;/code&gt;, and adding URL transfer to VictoriaMetrics from environment variables, but for now, we'll live with this version.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bonus: How execution control works with Go &lt;code&gt;context&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;In our &lt;code&gt;fetchAndPush()&lt;/code&gt; function we call &lt;code&gt;metrics.PushMetrics()&lt;/code&gt;, passing a &lt;em&gt;context&lt;/em&gt; to it.&lt;/p&gt;

&lt;p&gt;To make things clearer, let’s take another look at &lt;code&gt;InitPush()&lt;/code&gt;, since the use of context is most visible there.&lt;/p&gt;

&lt;p&gt;So, &lt;code&gt;InitPush()&lt;/code&gt; calls &lt;code&gt;InitPushExt()&lt;/code&gt;, and &lt;code&gt;InitPushExt()&lt;/code&gt; calls &lt;code&gt;InitPushExtWithOptions()&lt;/code&gt;, passing it an empty &lt;code&gt;context.Background()&lt;/code&gt; - &lt;code&gt;return InitPushExtWithOptions(context.Background() ...)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So, &lt;code&gt;InitPush()&lt;/code&gt; calls &lt;code&gt;InitPushExt()&lt;/code&gt;, and &lt;code&gt;InitPushExt()&lt;/code&gt; in its turn calls &lt;code&gt;InitPushExtWithOptions()&lt;/code&gt;, passing it an empty &lt;code&gt;context.Background()&lt;/code&gt; - the &lt;code&gt;return InitPushExtWithOptions(context.Background(), ...)&lt;/code&gt; part.&lt;/p&gt;

&lt;p&gt;Inside &lt;code&gt;InitPushExtWithOptions()&lt;/code&gt;, a goroutine (&lt;code&gt;go func() {}&lt;/code&gt;) is launched, where a local context is created:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
        &lt;span class="n"&gt;ctxLocal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pushMetrics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctxLocal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;writeMetrics&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Closing the channel with &lt;code&gt;cancel()&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;When &lt;code&gt;context.WithTimeout()&lt;/code&gt; is called, the following sequence occurs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;WithTimeout()&lt;/code&gt; calls &lt;a href="https://cs.opensource.google/go/go/+/refs/tags/go1.25.4:src/context/context.go;l=625" rel="noopener noreferrer"&gt;&lt;code&gt;WithDeadline()&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WithDeadline()&lt;/code&gt; calls &lt;a href="https://cs.opensource.google/go/go/+/refs/tags/go1.25.4:src/context/context.go;l=632" rel="noopener noreferrer"&gt;&lt;code&gt;WithDeadlineCause()&lt;/code&gt;&lt;/a&gt;:&lt;/li&gt;
&lt;li&gt;where an object is created - &lt;code&gt;c := &amp;amp;timerCtx{}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;the &lt;a href="https://cs.opensource.google/go/go/+/refs/tags/go1.25.4:src/context/context.go;l=662" rel="noopener noreferrer"&gt;&lt;code&gt;timerCtx&lt;/code&gt;&lt;/a&gt; struct embeds &lt;a href="https://cs.opensource.google/go/go/+/refs/tags/go1.25.4:src/context/context.go;l=431" rel="noopener noreferrer"&gt;&lt;code&gt;cancelCtx&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;which means &lt;code&gt;timerCtx&lt;/code&gt; now has access to all methods of &lt;code&gt;cancelCtx&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;next, &lt;code&gt;WithDeadlineCause()&lt;/code&gt; checks &lt;code&gt;if dur &amp;lt;= 0&lt;/code&gt;, meaning that the deadline has already passed:&lt;/li&gt;
&lt;li&gt;calls &lt;code&gt;c.cancel(true, DeadlineExceeded, cause)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;returns “&lt;code&gt;return c, func() { c.cancel(false, Canceled, nil) }&lt;/code&gt;", which is returned to &lt;code&gt;InitPushExtWithOptions()&lt;/code&gt; in the part &lt;code&gt;ctxLocal, cancel := context. WithTimeout() and "func() { c.cancel() }"&lt;/code&gt; and becomes &lt;code&gt;cancel()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;c.cancel()&lt;/code&gt; is a method of the &lt;code&gt;timerCtx&lt;/code&gt; - "&lt;code&gt;func (c *timerCtx) cancel()&lt;/code&gt;" - and it internally calls &lt;code&gt;c.cancelCtx.cancel()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;and &lt;code&gt;c.cancelCtx.cancel()&lt;/code&gt; is a method of the &lt;code&gt;cancelCtx&lt;/code&gt; structure - &lt;code&gt;func (c *cancelCtx) cancel()&lt;/code&gt;, which calls &lt;code&gt;d, _ := c.done.Load().(chan struct{})&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;and calls &lt;code&gt;close(d)&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;cancelCtx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;removeFromParent&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cause&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Load&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;chan&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;c.done.Load()&lt;/code&gt; is called from the &lt;code&gt;done&lt;/code&gt; field of the &lt;code&gt;cancelCtx&lt;/code&gt; structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;cancelCtx&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="n"&gt;done&lt;/span&gt; &lt;span class="n"&gt;atomic&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt; &lt;span class="c"&gt;// of chan struct{}, created lazily, closed by first cancel call&lt;/span&gt;
        &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where &lt;a href="https://pkg.go.dev/sync/atomic#Value.Load" rel="noopener noreferrer"&gt;&lt;code&gt;Load()&lt;/code&gt;&lt;/a&gt; is a method of the &lt;code&gt;Value&lt;/code&gt; structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Load&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is, in the &lt;code&gt;d, _ := c.done.Load().(chan struct{})&lt;/code&gt;, the &lt;code&gt;Load()&lt;/code&gt; method is executed, and then a type assertion to &lt;code&gt;chan struct{}&lt;/code&gt; is performed. After that, &lt;code&gt;d&lt;/code&gt; becomes a channel of type &lt;code&gt;chan struct{}&lt;/code&gt;, and &lt;code&gt;close(d)&lt;/code&gt; is called.&lt;/p&gt;

&lt;p&gt;And &lt;a href="https://pkg.go.dev/builtin#close" rel="noopener noreferrer"&gt;&lt;code&gt;close()&lt;/code&gt;&lt;/a&gt; is a built-in Go function that closes the channel passed as an argument.&lt;/p&gt;

&lt;p&gt;As soon as the &lt;code&gt;Done()&lt;/code&gt; channel is closed, all goroutines waiting on &lt;code&gt;&amp;lt;-ctx.Done()&lt;/code&gt; are unblocked and can finish their work properly.&lt;/p&gt;

&lt;p&gt;In the &lt;code&gt;InitPushExtWithOptions()&lt;/code&gt;, this is done here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="o"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;stopCh&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Done&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;stopCh&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
                              &lt;span class="o"&gt;...&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Closing the channel reads a zero value, which triggers the condition &lt;code&gt;case&lt;/code&gt; =&amp;gt;, which ends the loop by calling &lt;code&gt;return&lt;/code&gt; =&amp;gt;, which ends the entire &lt;code&gt;go func() {}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Okay.&lt;/p&gt;

&lt;p&gt;And where did the channel come from?&lt;/p&gt;

&lt;h3&gt;
  
  
  How the &lt;code&gt;context&lt;/code&gt; channel is created
&lt;/h3&gt;

&lt;p&gt;In order for a function or goroutine to continuously “listen” for the channel to be closed, we call &lt;code&gt;Done()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ticker&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewTicker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;ticker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;stopCh&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Done&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="o"&gt;...&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;stopCh&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;wg&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="n"&gt;wg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Done&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;ctx.Done()&lt;/code&gt; is a method of the &lt;a href="https://pkg.go.dev/context#Context" rel="noopener noreferrer"&gt;&lt;code&gt;Context interface&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Context&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="n"&gt;Done&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="k"&gt;chan&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where the channel itself is created:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;cancelCtx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Done&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="k"&gt;chan&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;chan&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;chan&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So, when called:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;context.WithTimeout()&lt;/code&gt; =&amp;gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WithDeadline()&lt;/code&gt; =&amp;gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WithDeadlineCause()&lt;/code&gt; =&amp;gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;c := &amp;amp;timerCtx {}&lt;/code&gt; which has &lt;code&gt;cancelCtx&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;а &lt;code&gt;cancelCtx {}&lt;/code&gt; which has &lt;code&gt;Done()&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And when we call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="n"&gt;rootCtx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rootCancel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NotifyContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Interrupt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SIGTERM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So, in the &lt;a href="https://cs.opensource.google/go/go/+/refs/tags/go1.25.4:src/os/signal/signal.go;l=278" rel="noopener noreferrer"&gt;&lt;code&gt;signal.NotifyContext()&lt;/code&gt;&lt;/a&gt;, we pass an empty parent context, and &lt;code&gt;signal.NotifyContext()&lt;/code&gt; creates and returns its own context using &lt;code&gt;context.WithCancel()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;NotifyContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signals&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Signal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stop&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CancelFunc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithCancel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;signalCtx&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
           &lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stop&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Therefore, at the end of our &lt;code&gt;main()&lt;/code&gt;, we can call reading from the channel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="c"&gt;// block until Ctrl+C cancels rootCtx&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;rootCtx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Done&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As soon as the channel is closed, control returns to the &lt;code&gt;main()&lt;/code&gt;, where &lt;code&gt;gocron.Stop()&lt;/code&gt; is executed and the program terminates.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;/em&gt; &lt;a href="https://rtfm.co.ua/en/golang-creating-openai-exporter-for-victoriametrics/" rel="noopener noreferrer"&gt;&lt;em&gt;RTFM: Linux, DevOps, and system administration&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>devops</category>
      <category>monitoring</category>
      <category>go</category>
      <category>todayileraned</category>
    </item>
    <item>
      <title>VictoriaMetrics: Churn Rate, High cardinality, metrics, and IndexDB</title>
      <dc:creator>Arseny Zinchenko</dc:creator>
      <pubDate>Tue, 04 Nov 2025 11:34:21 +0000</pubDate>
      <link>https://dev.to/setevoy/victoriametrics-churn-rate-high-cardinality-metrics-and-indexdb-2kji</link>
      <guid>https://dev.to/setevoy/victoriametrics-churn-rate-high-cardinality-metrics-and-indexdb-2kji</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi16jeyi716e5yd9w4cxn.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi16jeyi716e5yd9w4cxn.jpeg" width="800" height="298"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One day I received one of the default VictoriaMetrics alerts that are generated during the deployment of the Helm chart &lt;a href="https://github.com/VictoriaMetrics/helm-charts/tree/master/charts/victoria-metrics-k8s-stack" rel="noopener noreferrer"&gt;victoria-metrics-k8s-stack&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzc5x0oeu1asawlio0wwp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzc5x0oeu1asawlio0wwp.png" width="632" height="255"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I thought about writing a short post like “What is Churn Rate and how to fix it,” but in the end, I ended up diving deep into how VictoriaMetrics works with data in general — and it turned out to be a very interesting topic.&lt;/p&gt;

&lt;p&gt;Let’s first briefly discuss what “metrics” and time series are, and then see how they affect system resources — CPU, memory, and disk.&lt;/p&gt;

&lt;h3&gt;
  
  
  Metric vs Time Series vs Sample
&lt;/h3&gt;

&lt;p&gt;We all deal with metrics in monitoring — whether it’s Prometheus, VictoriaMetrics, or InfluxDB — and we then use these metrics in our Grafana dashboards or VMAlert alert rules.&lt;/p&gt;

&lt;p&gt;But what exactly is a “&lt;em&gt;metric&lt;/em&gt;”? And what are time series, samples, and data points? How does the number of different values of a single label for a metric affect disk and memory usage?&lt;/p&gt;

&lt;p&gt;For example, in &lt;a href="https://rtfm.co.ua/en" rel="noopener noreferrer"&gt;my blog&lt;/a&gt;, I usually just use the word “metric” because in 99% of cases, that’s enough to describe the object in question.&lt;/p&gt;

&lt;p&gt;However, to work effectively with monitoring systems, it is necessary to understand the difference between these concepts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Contents
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Metric vs Time Series vs Sample&lt;/li&gt;
&lt;li&gt;What is Metric?&lt;/li&gt;
&lt;li&gt;What is the Time Series?&lt;/li&gt;
&lt;li&gt;What are Sample and Data Points?&lt;/li&gt;
&lt;li&gt;High Cardinality vs High Churn rate&lt;/li&gt;
&lt;li&gt;“Life of a Metric”&lt;/li&gt;
&lt;li&gt;“Write-path”: vminsert та vmstorage&lt;/li&gt;
&lt;li&gt;vmstorage, IndexDB, and TSID&lt;/li&gt;
&lt;li&gt;vmstorage and data storage on disk&lt;/li&gt;
&lt;li&gt;“Read-path”: search for data from vmselect and vmstorage&lt;/li&gt;
&lt;li&gt;Practical example: recording 10,000 metrics and 10,000 labels&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What is Metric?
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Metric: what is measured&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For example  — &lt;code&gt; cpu\_usage&lt;/code&gt;, &lt;code&gt;memory\_free&lt;/code&gt;, &lt;code&gt;http\_requests\_total&lt;/code&gt;, &lt;code&gt;database\_connections&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The VictoriaMetrics documentation contains a very precise idea — it’s like the variable names through which we transfer data, see &lt;a href="https://docs.victoriametrics.com/victoriametrics/keyconcepts/#structure-of-a-metric" rel="noopener noreferrer"&gt;Structure of a metric&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A metric has its own name and, optionally, a set of labels (or tags) that allow you to add more context to a specific measurement.&lt;/p&gt;

&lt;p&gt;In addition, labels affect how data for this metric will be stored and searched.&lt;/p&gt;

&lt;p&gt;In other words, a metric is a “scheme” that describes what we are measuring and the characteristics (labels) by which we can group data.&lt;/p&gt;

&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Metric: "cpu_usage{server, core}"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;metric name: cpu_usage&lt;/li&gt;
&lt;li&gt;label name: server&lt;/li&gt;
&lt;li&gt;label name: core&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What is the Time Series?
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Time Series: a sequence of data&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the complete sequence of records grouped for a specific metric and its labels with values — that is, a set of &lt;code&gt;metric\_name{label\_name="label\_value"}&lt;/code&gt; - and sorted by time.&lt;/p&gt;

&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Metric: "cpu_usage{server, core}"
├── Time series: cpu_usage{server="web01", core="0"}
│ ├── 1753857852, 75.5
│ ├── 1753857912, 76.2
│ ├── 1753857972, 74.8
│ └── 1753858032, 73.1
├── Time series: cpu_usage{server="web01", core="1"}
│ ├── 1753857852, 82.3
│ ├── 1753857912, 81.7
│ └── ...
└── Time series: cpu_usage{server="web02", core="0"}
    ├── 1753857852, 45.2
    ├── 1753857912, 47.8
    └── ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, for the metric &lt;code&gt;cpu\_usage{server, core}&lt;/code&gt;, we have three different time series:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;code&gt;cpu\_usage{server="web01", core="0"}&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;At 1753857852 (Wed Jul 30 2025 06:44:12 GMT), the value was 75.5.&lt;/li&gt;
&lt;li&gt;At 1753857912 (Wed Jul 30 2025 06:45:12 GMT), the value was 76.2.&lt;/li&gt;
&lt;li&gt;…&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;&lt;code&gt;cpu\_usage{server="web01", core="1"}&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;at time 1753857852, the value was 82.3&lt;/li&gt;
&lt;li&gt;…&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cpu\_usage{server="web02", core="0"}&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;at time 1753857852, the value was 45.2&lt;/li&gt;
&lt;li&gt;…&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  What are Sample and Data Points?
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Sample: a specific record in a data sequence (time series).&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Sample and Data Point are synonyms and represent a single metric value at a specific point in time.&lt;/p&gt;

&lt;p&gt;It looks like (&lt;em&gt;timestamp&lt;/em&gt;, &lt;em&gt;value&lt;/em&gt;), for example, “1753857852 75.5” — that is, in Unix timestamp 1753857852 the value was 75.5%.&lt;/p&gt;

&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Metric: "cpu_usage{server, core}"
├── Time series: cpu_usage{server="web01", core="0"}
│ ├── Sample: 1753857852, 75.5
│ ├── Sample: 1753857912, 76.2
│ ├── Sample: 1753857972, 74.8
│ └── Sample: 1753858032, 73.1
├── Time series: cpu_usage{server="web01", core="1"}
│ ├── Sample: 1753857852, 82.3
│ ├── Sample: 1753857912, 81.7
│ └── ...
└── Time series: cpu_usage{server="web02", core="0"}
    ├── Sample: 1753857852, 45.2
    ├── Sample: 1753857912, 47.8
    └── ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For the time series &lt;code&gt;cpu\_usage{server="web01", core="0"}&lt;/code&gt;, we have four samples:&lt;/li&gt;
&lt;li&gt;1753857852, 75.5&lt;/li&gt;
&lt;li&gt;1753857912, 76.2&lt;/li&gt;
&lt;li&gt;1753857972, 74.8&lt;/li&gt;
&lt;li&gt;1753858032, 73.1&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the data for the entire observation period for each unique combination &lt;code&gt;cpu\_usage{server="some\_server", core="some\_core"}&lt;/code&gt; will form the same time series, even if this data is collected for years, until the value in either &lt;code&gt;server&lt;/code&gt; or &lt;code&gt;core&lt;/code&gt; changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  High Cardinality vs High Churn rate
&lt;/h3&gt;

&lt;p&gt;Both problems have the same “origin,” but differ slightly in essence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;High cardinality&lt;/strong&gt; is a “persistent problem” that affects data storage, indexing, and search.&lt;/p&gt;

&lt;p&gt;It occurs when we have many unique combinations of labels, even if the values of the metrics themselves are infrequent or cease to arrive.&lt;/p&gt;

&lt;p&gt;This leads to a large number of live and inactive series, which increases the size of IndexDB, memory usage, and search time. We will discuss IndexDB in more detail later.&lt;/p&gt;

&lt;p&gt;See &lt;a href="https://victoriametrics.com/blog/cardinality-explorer/" rel="noopener noreferrer"&gt;Cardinality explorer&lt;/a&gt; in VictoriaMetrics blogs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;High churn rate&lt;/strong&gt; is an “online problem” where we constantly create new time series due to changes in label values, especially short-lived or dynamic ones (as in Kubernetes  — &lt;code&gt; pod\_name&lt;/code&gt;, &lt;code&gt;container\_id&lt;/code&gt;, &lt;code&gt;job\_id&lt;/code&gt;, or something like &lt;code&gt;client\_ip&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;This creates a large stream of new entries in IndexDB, loading the CPU, memory, and disk.&lt;/p&gt;

&lt;h3&gt;
  
  
  “Life of a Metric”
&lt;/h3&gt;

&lt;p&gt;There is a very cool video that I saw many years ago, &lt;a href="https://www.youtube.com/watch?v=6wGAVxe7cik" rel="noopener noreferrer"&gt;The Inner Life of the Cell&lt;/a&gt;, for some reason it came to mind here.&lt;/p&gt;

&lt;p&gt;To understand how the number of labels (more precisely, their values) affects the amount of data in the system and CPU and memory usage, let’s take a look at how the whole process in VictoriaMetrics works “under the hood”.&lt;/p&gt;

&lt;p&gt;A wonderful series of posts by &lt;a href="https://www.linkedin.com/in/quocphuong/" rel="noopener noreferrer"&gt;Phuong Le&lt;/a&gt; will help us with this: &lt;a href="https://victoriametrics.com/blog/vmagent-how-it-works/" rel="noopener noreferrer"&gt;How vmagent Collects and Ships Metrics Fast with Aggregation, Deduplication, and More&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;There are seven parts, and for a truly “deep dive” into the internal architecture of VictoriaMetrics I highly recommend reading them.&lt;/p&gt;

&lt;p&gt;But now we will quickly go through the process of adding new data and searching for it, and focus more on the Churn Rate issue.&lt;/p&gt;

&lt;h3&gt;
  
  
  “Write-path”: &lt;code&gt;vminsert&lt;/code&gt; and &lt;code&gt;vmstorage&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;So, let’s start from the beginning: &lt;code&gt;vmagent&lt;/code&gt; collects metrics from exporters, and then this data must be written to &lt;code&gt;vmstorage&lt;/code&gt; via &lt;code&gt;vminsert&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In the case of &lt;code&gt;vmsingle&lt;/code&gt;, all components work in a single process, but for a better picture, let's separate them.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;vminsert&lt;/code&gt; collects data into its memory, then sends it to &lt;code&gt;vmstorage&lt;/code&gt; in blocks of up to 100 megabytes.&lt;/p&gt;

&lt;p&gt;At the beginning of each block, &lt;code&gt;vminsert&lt;/code&gt; sets the total size of the block, after which &lt;code&gt;vmstorage&lt;/code&gt; begins to read the data in it in blocks of &lt;em&gt;24+n&lt;/em&gt; bytes, rows:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fel5hefeanvwriyvtt56b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fel5hefeanvwriyvtt56b.png" width="596" height="582"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The first 8 bytes indicate the size &lt;em&gt;n&lt;/em&gt; — the size of the next sector, which contains the name of the metric and its labels.&lt;/li&gt;
&lt;li&gt;the second sector — these &lt;em&gt;n&lt;/em&gt; bytes with the metric name and labels&lt;/li&gt;
&lt;li&gt;The third sector, 8 bytes in size, contains the sample value (“75.5” from the examples above).&lt;/li&gt;
&lt;li&gt;The fourth contains a timestamp, another 8 bytes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As a result, a row of 8*3 bytes (24) + &lt;em&gt;n&lt;/em&gt; bytes is formed, where &lt;em&gt;n&lt;/em&gt; is the length of the metric name and its label.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;vmstorage&lt;/code&gt; forms its own blocks, with a maximum of 10,000 lines in each:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fas5ewfzrkfz08oqdnvgj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fas5ewfzrkfz08oqdnvgj.png" width="800" height="530"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;vmstorage&lt;/code&gt;, IndexDB, and TSID
&lt;/h3&gt;

&lt;p&gt;Then the most interesting magic begins — Time Series ID, or TSID.&lt;/p&gt;

&lt;p&gt;For each unique combination of metric+label+label value, VictoriaMetrics has its own unique ID, which is used to store data and for subsequent data retrieval.&lt;/p&gt;

&lt;p&gt;TSID itself is an identifier (see &lt;a href="https://github.com/VictoriaMetrics/VictoriaMetrics/blob/master/lib/storage/tsid.go#L17" rel="noopener noreferrer"&gt;&lt;code&gt;type TSID struct&lt;/code&gt;&lt;/a&gt;), a purely internal mechanism of VictoriaMetrics itself, which values, unfortunately, we cannot see anywhere in logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// TSID is unique id for a time series.
//
// Time series blocks are sorted by TSID.
type TSID struct {
  MetricGroupID uint64

  JobID uint32

  InstanceID uint32

  // MetricID is the unique id of the metric (time series).
  //
  // All the other TSID fields may be obtained by MetricID.
  MetricID uint64
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a set of metric names and their tags (labels), &lt;code&gt;vmstorage&lt;/code&gt; first checks its TSID Cache. If we already have a generated TSID for this combination, we use it.&lt;/p&gt;

&lt;p&gt;If the data is not in the cache (the value of &lt;code&gt;vm\_slow\_row\_inserts\_total&lt;/code&gt; increases), &lt;code&gt;vmstorage&lt;/code&gt; refers to the &lt;a href="https://docs.victoriametrics.com/victoriametrics/#indexdb" rel="noopener noreferrer"&gt;IndexDB&lt;/a&gt; and starts searching for TSID there.&lt;/p&gt;

&lt;p&gt;If TSID is found in IndexDB, it is added to the &lt;code&gt;vmstorage&lt;/code&gt; cache, and the process continues:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjc05102klr0fsrxy3spx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjc05102klr0fsrxy3spx.png" width="800" height="531"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If these are entirely new metric names and labels with their values, a new TSID is generated and registered in the &lt;code&gt;vmstorage&lt;/code&gt; cache.&lt;/p&gt;

&lt;p&gt;IndexDB stores two indexes, each containing several mappings between fields and IDs, as described in the section &lt;a href="https://victoriametrics.com/blog/vmstorage-how-indexdb-works/index.html#how-indexdb-is-structured" rel="noopener noreferrer"&gt;How IndexDB is Structured&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1 — Tag to metric IDs (Global index)&lt;/strong&gt;: each tag (label) is mapped to a metric name (its ID)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2 — Metric ID to TSID (Global index)&lt;/strong&gt;: The ID of each metric is mapped to TSID.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3 — Metric ID to metric name (Global index)&lt;/strong&gt;: mapping the actual name of the metric to its ID&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4 — Deleted metric ID:&lt;/strong&gt; tracker of deleted metric IDs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5 — Date to metric ID (Per-day index)&lt;/strong&gt;: mapping dates to metric IDs for quick date-based searches (“is there data for this metric on this date?”)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;6 — Date with tag to metric IDs (Per-day index)&lt;/strong&gt;: similar to the first &lt;strong&gt;Tag to metric IDs&lt;/strong&gt; mapping, but by date&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;7 — Date with metric name to TSID (Per-day index)&lt;/strong&gt;: similar to the second &lt;strong&gt;Metric ID to TSID&lt;/strong&gt; mapping, but by metric names and dates&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These indexes are stored in memory and periodically flushed to persistent storage IndexDB in the &lt;code&gt;indexdb/&lt;/code&gt; directory, where, as in the &lt;code&gt;data/&lt;/code&gt; directory where the time series themselves are stored, data is merged to optimize storage and search.&lt;/p&gt;

&lt;p&gt;For more details, see Part 3 of VictoriaMetrics’ blogs — &lt;a href="https://victoriametrics.com/blog/vmstorage-retention-merging-deduplication/index.html#how-data-gets-written-to-disk" rel="noopener noreferrer"&gt;How vmstorage Processes Data: Retention, Merging, Deduplication&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Returning to the issue of &lt;strong&gt;Churn Rate&lt;/strong&gt; and &lt;strong&gt;High cardinality&lt;/strong&gt; , each individual metric + label creates separate TSIDs, mappings are created in indexes for each label, and with a large amount of new data constantly being written from memory to disk, disk operations are called more often, resulting in a load on the CPU, memory, and disk I/O operations.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;vmstorage&lt;/code&gt; and data storage on disk
&lt;/h3&gt;

&lt;p&gt;Actually, we have already seen the most interesting part — the roles of IndexDB and TSID — but let’s go through the rest of the process.&lt;/p&gt;

&lt;p&gt;From the data received from vminsert, we read the data and form our own blocks from rows.&lt;/p&gt;

&lt;p&gt;Each row &lt;code&gt;vmstorage&lt;/code&gt; no longer stores the name of the metric but its TSID, and for each TSID it contains records with values and time (actually, time series):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw29qapm62drljg96iis4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw29qapm62drljg96iis4.png" width="800" height="425"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;They are then stored in memory in “raw-row shards,” after which they form in-memory LSM parts (see &lt;a href="https://en.wikipedia.org/wiki/Log-structured_merge-tree" rel="noopener noreferrer"&gt;Log-structured merge-tree&lt;/a&gt; and &lt;a href="https://docs.yugabyte.com/preview/architecture/docdb/lsm-sst/" rel="noopener noreferrer"&gt;LSM tree and Sorted string tables (SST)&lt;/a&gt;):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F463yb69mkoz0tmmfm39m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F463yb69mkoz0tmmfm39m.png" width="800" height="813"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Which are then recorded on a disk:&lt;/p&gt;

&lt;p&gt;And on the disk, as with IndexDB data, Merge Process, Deduplication, and Downsampling occur in a similar manner.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftl9gbny5ta0eof54afwi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftl9gbny5ta0eof54afwi.png" width="799" height="445"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But what interests us is how it looks on the disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk exec -ti vmsingle-vm-k8s-stack-ff6f9bf4c-qt2mj -- tree victoria-metrics-data/data
victoria-metrics-data/data
├── big
│ ├── 2025_09
│ │ └── 18688A4D78E7FBFB
│ │ ├── index.bin
│ │ ├── metadata.json
│ │ ├── metaindex.bin
│ │ ├── timestamps.bin
│ │ └── values.bin
│ ├── 2025_10
│ │ ├── 186A34EE1061F960
│ │ │ ├── index.bin
│ │ │ ├── metadata.json
│ │ │ ├── metaindex.bin
│ │ │ ├── timestamps.bin
│ │ │ └── values.bin
│ │ ├── 186CDDD43EA4892F...

── small
    ├── 2025_09
    │ ├── 18688A4D78E8044E
    │ │ ├── index.bin
    │ │ ├── metadata.json
    │ │ ├── metaindex.bin
    │ │ ├── timestamps.bin
    │ │ └── values.bin
    │ ├── 18688A4D78E80B8F
    │ │ ├── index.bin
    │ │ ├── metadata.json
    │ │ ├── metaindex.bin
    │ │ ├── timestamps.bin
    │ │ └── values.bin
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, data from in-memory parts is “dumped” into small parts, and small parts are then merged into big parts.&lt;/p&gt;

&lt;p&gt;Each part contains its own index, which is responsible for mapping data to timestamps and values:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdvbf6bh773egj4qrgzru.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdvbf6bh773egj4qrgzru.png" width="800" height="305"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  “Read-path”: search for data from &lt;code&gt;vmselect&lt;/code&gt; and &lt;code&gt;vmstorage&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;When we search for data, &lt;code&gt;vmselect&lt;/code&gt; sends a request to &lt;code&gt;vmstorage&lt;/code&gt; with metrics, labels (tags), and the date for which the search should be performed.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;vmstorage&lt;/code&gt; in IndexDB finds the corresponding MetricIDs for all metrics that have this tag using the &lt;strong&gt;tag to metric IDs&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Next, based on the Metric ID IndexDB, it finds the corresponding TSIDs in the &lt;strong&gt;metric ID to TSID&lt;/strong&gt; records and returns them to &lt;code&gt;vmstorage&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;With TSID  — &lt;code&gt; vmtorage&lt;/code&gt; checks in-memory, small, and big parts, searching for the required TSID in the &lt;code&gt;metaindex.bin&lt;/code&gt; files.&lt;/p&gt;

&lt;p&gt;And once it finds the right &lt;code&gt;metadata.bin&lt;/code&gt; it reads the corresponding &lt;code&gt;index.bin&lt;/code&gt;, which already tells you in which lines of &lt;code&gt;timestamp.bin&lt;/code&gt; and &lt;code&gt;values.bin&lt;/code&gt; to find the necessary data, which is then returned to the &lt;code&gt;vmselect&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Practical example: recording 10,000 metrics and 10,000 labels
&lt;/h3&gt;

&lt;p&gt;It’s all interesting to read about in theory, but let’s see some practical examples, because it’s always interesting to see how it looks in reality.&lt;/p&gt;

&lt;p&gt;What we will do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;will launch two containers with VictoriaMetrics&lt;/li&gt;
&lt;li&gt;will write 10,000 metrics to each via API, but:&lt;/li&gt;
&lt;li&gt;in one instance, all metrics of the label will have the same value&lt;/li&gt;
&lt;li&gt;in the second instance, the label value will constantly change&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And then we’ll see how this affected the data size.&lt;/p&gt;

&lt;p&gt;Create directories:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ mkdir vm-data-light
$ mkdir vm-data-heavy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Launch two containers  — &lt;code&gt; vm-light&lt;/code&gt; and &lt;code&gt;vm-heavy&lt;/code&gt;, connect each to the corresponding directory - &lt;code&gt;./vm-data-light&lt;/code&gt; and &lt;code&gt;./ vm-data-heavy&lt;/code&gt;, each listening to its own TCP port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ docker run --rm --name vm-light -p 8428:8428 -v ./vm-data-light:/victoria-metrics-data victoriametrics/victoria-metrics
$ docker run --rm --name vm-heavy -p 8429:8428 -v ./vm-data-heavy:/victoria-metrics-data victoriametrics/victoria-metrics
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let’s check the size of the directories now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ du -sh vm-data-light/
76K vm-data-light/

$ du -sh vm-data-heavy/
76K vm-data-heavy/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the number of files in them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ find vm-data-light/ -type f | wc -l
5

$ find vm-data-heavy/ -type f | wc -l
5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everywhere it’s the same.&lt;/p&gt;

&lt;p&gt;Now we write two scripts — “light” and “heavy”.&lt;/p&gt;

&lt;p&gt;First, the “light” version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#!/usr/bin/env bash

for i in $(seq 1 10000); do
  echo "my_metric{label=\"value-1\"} $i" | curl -s \
    --data-binary @- \
    http://localhost:8428/api/v1/import/prometheus
done

echo "DONE: stable series sent"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, in a loop from 1 to 10,000, we record the metric &lt;code&gt;my\_metric{label="value-1"}&lt;/code&gt;, but each time we simply increase the only value we store.&lt;/p&gt;

&lt;p&gt;The second script is the “heavy” version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#!/usr/bin/env bash

for i in $(seq 1 10000); do
  echo "my_metric{label=\"value-$i\"} $i" | curl -s \
    --data-binary @- \
    http://localhost:8429/api/v1/import/prometheus
done

echo "DONE: high churn series sent"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is similar, but here we also use the value of the variable &lt;code&gt;$i&lt;/code&gt; to change the value in the label - &lt;code&gt;my\_metric{label="value-$i"} $i&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Run tests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ bash light.sh

$ bash heavy.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And compare the data.&lt;/p&gt;

&lt;p&gt;Data size in the &lt;code&gt;data/&lt;/code&gt; directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ du -sh vm-data-light/data/
152K vm-data-light/data/

$ du -sh vm-data-heavy/data/
372K vm-data-heavy/data/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Data size in the &lt;code&gt;indexdb/&lt;/code&gt; directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ du -sh vm-data-light/indexdb/
56K vm-data-light/indexdb/

$ du -sh vm-data-heavy/indexdb/
764K vm-data-heavy/indexdb/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Number of files in the &lt;code&gt;data/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ find vm-data-light/data/ -type f | wc -l
26

$ find vm-data-heavy/data/ -type f | wc -l
26
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Number of files in the &lt;code&gt;indexdb/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ find vm-data-light/indexdb/ -type f | wc -l
8

$ find vm-data-heavy/indexdb/ -type f | wc -l
53
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;8 vs 53!&lt;/p&gt;

&lt;p&gt;The directories and files tree in &lt;code&gt;vm-data-light/data/&lt;/code&gt; and &lt;code&gt;vm-data-heavy/data&lt;/code&gt;/ will be the same, but let's take a look at IndexDB.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;vm-data-light/indexdb/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ tree vm-data-light/indexdb/
vm-data-light/indexdb/
├── 1872FB055ACC4FF8
│ └── parts.json
├── 1872FB055ACC4FF9
│ ├── 1872FB055C5E523F
│ │ ├── index.bin
│ │ ├── items.bin
│ │ ├── lens.bin
│ │ ├── metadata.json
│ │ └── metaindex.bin
│ └── parts.json
├── 1872FB055ACC4FFA
│ └── parts.json
└── snapshots

6 directories, 8 files
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whereas in the &lt;code&gt;vm-data-heavy/indexdb/&lt;/code&gt; the picture is absolutely different:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ tree vm-data-heavy/indexdb/
vm-data-heavy/indexdb/
├── 1872FB05F8C559B2
│ └── parts.json
├── 1872FB05F8C559B3
│ ├── 1872FB05FA9633D4
│ │ ├── index.bin
│ │ ├── items.bin
│ │ ├── lens.bin
│ │ ├── metadata.json
│ │ └── metaindex.bin
│ ├── 1872FB05FA9633D5
│ │ ├── index.bin
│ │ ├── items.bin
│ │ ├── lens.bin
│ │ ├── metadata.json
│ │ └── metaindex.bin
│ ├── 1872FB05FA9633D6
│ │ ├── index.bin
│ │ ├── items.bin
│ │ ├── lens.bin
│ │ ├── metadata.json
│ │ └── metaindex.bin
│ ├── 1872FB05FA9633D8
│ │ ├── index.bin
│ │ ├── items.bin
│ │ ├── lens.bin
│ │ ├── metadata.json
│ │ └── metaindex.bin
│ ├── 1872FB05FA9633DA
│ │ ├── index.bin
│ │ ├── items.bin
│ │ ├── lens.bin
│ │ ├── metadata.json
│ │ └── metaindex.bin
│ ├── 1872FB05FA9633DB
│ │ ├── index.bin
│ │ ├── items.bin
│ │ ├── lens.bin
│ │ ├── metadata.json
│ │ └── metaindex.bin
│ ├── 1872FB05FA9633DC
│ │ ├── index.bin
│ │ ├── items.bin
│ │ ├── lens.bin
│ │ ├── metadata.json
│ │ └── metaindex.bin
│ ├── 1872FB05FA9633DD
│ │ ├── index.bin
│ │ ├── items.bin
│ │ ├── lens.bin
│ │ ├── metadata.json
│ │ └── metaindex.bin
│ ├── 1872FB05FA9633DE
│ │ ├── index.bin
│ │ ├── items.bin
│ │ ├── lens.bin
│ │ ├── metadata.json
│ │ └── metaindex.bin
│ ├── 1872FB05FA9633DF
│ │ ├── index.bin
│ │ ├── items.bin
│ │ ├── lens.bin
│ │ ├── metadata.json
│ │ └── metaindex.bin
│ └── parts.json
├── 1872FB05F8C559B4
│ └── parts.json
└── snapshots

15 directories, 53 files
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;vm-data-light/indexdb&lt;/code&gt;: &lt;strong&gt;6&lt;/strong&gt; directories, &lt;strong&gt;8&lt;/strong&gt;  files&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;vm-data-heavy/indexdb&lt;/code&gt;: &lt;strong&gt;15&lt;/strong&gt; directories, &lt;strong&gt;53&lt;/strong&gt;  files&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In addition, we can compare the statistics with &lt;a href="https://docs.victoriametrics.com/victoriametrics/url-examples/#apiv1statustsdb" rel="noopener noreferrer"&gt;&lt;code&gt;/api/v1/status/tsdb&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Light version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s http://localhost:8428/prometheus/api/v1/status/tsdb | jq
{
  "status": "success",
  "data": {
    "totalSeries": 1,
    "totalLabelValuePairs": 2,
    "seriesCountByMetricName": [
      {
        "name": "my_metric",
        "value": 1,
        "requestsCount": 0,
        "lastRequestTimestamp": 0
      }
    ],
    "seriesCountByLabelName": [
      {
        "name": " __name__",
        "value": 1
      },
      {
        "name": "label",
        "value": 1
      }
    ],
    "seriesCountByFocusLabelValue": [],
    "seriesCountByLabelValuePair": [
      {
        "name": " __name__ =my_metric",
        "value": 1
      },
      {
        "name": "label=value-1",
        "value": 1
      }
    ],
    "labelValueCountByLabelName": [
      {
        "name": " __name__",
        "value": 1
      },
      {
        "name": "label",
        "value": 1
      }
    ]
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whereas in the “heavy version” there are just more numbers of everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s http://localhost:8429/prometheus/api/v1/status/tsdb | jq
{
  "status": "success",
  "data": {
    "totalSeries": 10000,
    "totalLabelValuePairs": 20000,
    "seriesCountByMetricName": [
      {
        "name": "my_metric",
        "value": 10000,
        "requestsCount": 0,
        "lastRequestTimestamp": 0
      }
    ],
    "seriesCountByLabelName": [
      {
        "name": " __name__",
        "value": 10000
      },
      {
        "name": "label",
        "value": 10000
      }
    ],
    "seriesCountByFocusLabelValue": [],
    "seriesCountByLabelValuePair": [
      {
        "name": " __name__ =my_metric",
        "value": 10000
      },
      ...
      {
        "name": "label=value-1003",
        "value": 1
      },
      {
        "name": "label=value-1004",
        "value": 1
      }
    ],
    "labelValueCountByLabelName": [
      {
        "name": "label",
        "value": 10000
      },
      {
        "name": " __name__",
        "value": 1
      }
    ]
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That’s all, actually.&lt;/p&gt;

&lt;p&gt;I’m going to rewrite the configs for &lt;code&gt;vmagent&lt;/code&gt; to drop some of the labels, especially from Karpenter (see &lt;a href="https://rtfm.co.ua/karpenter-monitoring-ta-grafana-dashboard-dlya-kubernetes-workernodes/" rel="noopener noreferrer"&gt;Karpenter: monitoring and Grafana dashboard for Kubernetes WorkerNodes&lt;/a&gt;) - because there are dozens of them for each metric.&lt;/p&gt;

&lt;p&gt;For relabeling in VictoriaMetrics, see &lt;a href="https://docs.victoriametrics.com/victoriametrics/relabeling/" rel="noopener noreferrer"&gt;Relabeling cookbook&lt;/a&gt;. And I hope to create another post about that soon.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;/em&gt; &lt;a href="https://rtfm.co.ua/en/victoriametrics-churn-rate-high-cardinality-metrics-an-indexdb/" rel="noopener noreferrer"&gt;&lt;em&gt;RTFM: Linux, DevOps, and system administration&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>monitoring</category>
      <category>devops</category>
      <category>todayilearned</category>
    </item>
    <item>
      <title>Kubernetes: monitoring processes with process-exporter</title>
      <dc:creator>Arseny Zinchenko</dc:creator>
      <pubDate>Mon, 03 Nov 2025 11:21:30 +0000</pubDate>
      <link>https://dev.to/setevoy/kubernetes-monitoring-processes-with-process-exporter-2c0k</link>
      <guid>https://dev.to/setevoy/kubernetes-monitoring-processes-with-process-exporter-2c0k</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flijo0rv8bgvtv6m6x4z6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flijo0rv8bgvtv6m6x4z6.png" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We are debugging one issue with memory usage in Kubernetes Pods, and decided to look at the memory and number of processes on the nodes.&lt;/p&gt;

&lt;p&gt;The problem is that a Kubernetes Pod with Livekit usually consumes about 2 gigabytes of memory, but sometimes there are spikes of up to 10–11 gigabytes, which causes the Pod to crash:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp1jy1mux45ba9ievbvxj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp1jy1mux45ba9ievbvxj.png" width="800" height="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What we want to determine is whether it is one process that starts to “eat” so much memory, or whether many processes are simply being created in the container?&lt;/p&gt;

&lt;p&gt;The simplest option here is to use &lt;a href="https://github.com/ncabatoff/process-exporter" rel="noopener noreferrer"&gt;Prometheus Process Exporter&lt;/a&gt;, which runs as a DaemonSet, creates its own container on each WorkerNode, and collects statistics from &lt;a href="https://www.kernel.org/doc/html/latest/filesystems/proc.html" rel="noopener noreferrer"&gt;&lt;code&gt;/proc&lt;/code&gt;&lt;/a&gt; for all or selected processes on EC2.&lt;/p&gt;

&lt;p&gt;There is a good (and working) Helm chart &lt;a href="https://artifacthub.io/packages/helm/kir4h/process-exporter" rel="noopener noreferrer"&gt;kir4h/process-exporter&lt;/a&gt;, let’s take it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Contents
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Starting Process Exporter&lt;/li&gt;
&lt;li&gt;Creating Name Groups&lt;/li&gt;
&lt;li&gt;Name Group Template variables and information about each process&lt;/li&gt;
&lt;li&gt;The result of our debate&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Starting Process Exporter
&lt;/h3&gt;

&lt;p&gt;Add the repository, install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ helm repo add kir4h https://kir4h.github.io/charts
$ helm install my-process-exporter kir4h/process-exporter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or, in our case, we install it via &lt;a href="https://helm.sh/docs/helm/helm_dependency/" rel="noopener noreferrer"&gt;Helm dependency&lt;/a&gt; and add the chart to &lt;code&gt;Chart.yaml&lt;/code&gt; of our monitoring stack chart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
- name: process-exporter
  version: ~1.0
  repository: https://kir4h.github.io/charts
  condition: process-exporter.enabled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add values for it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
process-exporter:
  enabled: true
  tolerations:
  - effect: NoSchedule
    operator: Exists
  - key: CriticalAddonsOnly
    operator: Exists
    effect: NoSchedule
  - key: CriticalAddonsOnly
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy and check DaemonSet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk get ds
NAME DESIRED CURRENT READY UP-TO-DATE AVAILABLE NODE SELECTOR AGE
atlas-victoriametrics-process-exporter 9 9 9 9 9 &amp;lt;none&amp;gt; 76m
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And check ServiceMonitor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk get serviceMonitor | grep process
atlas-victoriametrics-process-exporter 3d3h
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For VictoriaMetrics, &lt;a href="https://docs.victoriametrics.com/operator/resources/vmservicescrape/" rel="noopener noreferrer"&gt;VMServiceScrape&lt;/a&gt; is automatically created:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk get VMServiceScrape | grep process
atlas-victoriametrics-process-exporter 3d3h operational
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We check whether there are metrics, for example, for &lt;a href="https://github.com/ncabatoff/process-exporter?tab=readme-ov-file#memory_bytes-gauge" rel="noopener noreferrer"&gt;&lt;code&gt;namedprocess_namegroup_memory_bytes&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foluclqupbdvcedc4wpb4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foluclqupbdvcedc4wpb4.png" width="800" height="1149"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating Name Groups
&lt;/h3&gt;

&lt;p&gt;We now have data on all processes — we don’t need it.&lt;/p&gt;

&lt;p&gt;Specifically, in our case, we are interested in statistics on our Backend API processes, Python processes.&lt;/p&gt;

&lt;p&gt;We have three main ones: the Backend API itself, Celery Workers, and Livekit itself, and each service runs in its own Pods from separate Deployments.&lt;/p&gt;

&lt;p&gt;Find processes in the submissions and see how they are launched.&lt;/p&gt;

&lt;p&gt;Backend API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;root@backend-api-deployment-5695989cb5-rjhv9:/app# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.2 40348 34712 ? Ss 07:59 0:02 /usr/local/bin/python /usr/local/bin/gunicorn challenge_backend.run_api:app [...]
root 7 1.2 2.5 2075368 414564 ? Sl 07:59 1:32 /usr/local/bin/python /usr/local/bin/gunicorn challenge_backend.run_api:app [...]
root 8 1.1 2.6 1999384 422228 ? Sl 07:59 1:23 /usr/local/bin/python /usr/local/bin/gunicorn challenge_backend.run_api:app [...]
root 9 1.2 2.6 2002492 429192 ? Sl 07:59 1:30 /usr/local/bin/python /usr/local/bin/gunicorn challenge_backend.run_api:app [...]
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Celery workers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;root@backend-celery-workers-deployment-5bc64557c8-zbq2j:/app# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.2 1.4 544832 236720 ? Ss 07:27 0:24 /usr/local/bin/python /usr/local/bin/celery -A celery_app.app worker [...]
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ta Livekit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;root@backend-livekit-agent-deployment-7d9bf86564-qgjzb:/app# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.4 1.8 2112944 294772 ? Ssl 07:06 0:46 python -m cortex.livekit_agent.main start
root 24 0.0 0.0 15788 12860 ? S 07:06 0:00 /usr/local/bin/python -c from multiprocessing.resource_tracker import main;main (34)
root 25 0.0 0.6 342976 102852 ? S 07:06 0:02 /usr/local/bin/python -c from multiprocessing.forkserver import main [...]
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the configuration for process-exporter — describe &lt;code&gt;nameMatchers&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
process-exporter:
  enabled: true
  tolerations:
    operator: Exists
    effect: NoSchedule
  - key: CriticalAddonsOnly
  config:
    # metrics will be broken down by thread name as well as group name
    threads: true
    # any process that otherwise isn't part of its own group becomes part of the first group found (if any) when walking the process tree upwards
    children: true
    # means that on each scrape the process names are re-evaluated
    recheck: false
    # remove_empty_groups drop empty groups if no processes found
    remove_empty_groups: true
    nameMatchers: 
      # gunicorn (python + uvicorn workers)
      - name: "gunicorn"
        exe:
          - /usr/local/bin/python
        cmdline:
          - ".*gunicorn.*"

      # celery worker
      - name: "celery-worker"
        exe:
          - /usr/local/bin/python
        cmdline:
          - ".*celery.*worker.*"

      # livekit agent
      - name: "livekit-agent"
        exe:
          - python
          - /usr/local/bin/python
        cmdline:
          - ".*cortex.livekit_agent.main.*"

      # livekit multiprocessing helpers
      - name: "livekit-multiproc"
        exe:
          - /usr/local/bin/python
        cmdline:
          - ".*multiprocessing.*"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, the &lt;code&gt;exe&lt;/code&gt; is a list of executables (there can be several), and in &lt;code&gt;cmdline&lt;/code&gt; are the arguments with which the process is launched.&lt;/p&gt;

&lt;p&gt;That is, for Livekit, we have &lt;em&gt;exe&lt;/em&gt;  —  “&lt;code&gt;/usr/local/bin/python&lt;/code&gt;", and &lt;em&gt;cmdline&lt;/em&gt; - "&lt;code&gt;-c from multiprocessing.resource_tracker [...] " or "-c from multiprocessing.forkserver [...]&lt;/code&gt;".&lt;/p&gt;

&lt;p&gt;Let’s deploy, and now there are only three groups left:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv31a9xbza7gx9uu6wk5x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv31a9xbza7gx9uu6wk5x.png" width="800" height="1031"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But there are nuances.&lt;/p&gt;

&lt;p&gt;First, statistics are collected from each node across the entire group of processes.&lt;/p&gt;

&lt;p&gt;That is, if we do the following:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sum(namedprocess_namegroup_memory_bytes{memtype="resident", groupname="celery-worker"}) by (groupname, instance, pod)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This will give us the sum of all RSSs of all Celery workers on the node where the corresponding process-exporter Pod is running:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwjqhdols7ypici349dvz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwjqhdols7ypici349dvz.png" width="799" height="648"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The second problem is that Process Exporter does not have a label named WorkerNode from which metrics are collected.&lt;/p&gt;

&lt;p&gt;Therefore, we can only search manually here — by Pod IP (label &lt;code&gt;instance&lt;/code&gt;) we can find its Node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk get pod -o wide | grep 10.0.45.166
atlas-victoriametrics-process-exporter-4zdzl 1/1 Running 0 6m51s 10.0.45.166 ip-10-0-40-195.ec2.internal &amp;lt;none&amp;gt; &amp;lt;none&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And then see what kind of holes there are on this node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk describe node ip-10-0-40-195.ec2.internal | grep celery
  dev-backend-api-ns backend-celery-workers-deployment-5bc64557c8-hqhz4 200m (5%) 0 (0%) 1500Mi (10%) 0 (0%) 3h28m
  dev-backend-api-ns backend-celery-workers-long-running-deployment-57d7cb9984-nlfs4 200m (5%) 0 (0%) 1500Mi (10%) 0 (0%) 3h12m
  prod-backend-api-ns backend-celery-workers-deployment-5597dfd875-m7c2n 500m (12%) 0 (0%) 1500Mi (10%) 0 (0%) 99m
  staging-backend-api-ns backend-celery-workers-long-running-deployment-5bb44795b7-pcmj2 200m (5%) 0 (0%) 1500Mi (10%) 0 (0%) 103m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And now let’s take a look at the processes and their &lt;code&gt;RSS&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ip-10-0-40-195 ec2-user]# ps -eo rss,cmd | grep celery
232888 /usr/local/bin/python /usr/local/bin/celery -A celery_app.app worker --loglevel=info -Q default
241656 /usr/local/bin/python /usr/local/bin/celery -A celery_app.app worker --loglevel=info -Q default
...
239232 /usr/local/bin/python /usr/local/bin/celery -A celery_app.app worker --loglevel=info -Q default
252240 /usr/local/bin/python /usr/local/bin/celery -A celery_app.app worker --loglevel=info -Q default
 2416 grep --color=auto celery
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the graph, we have 4,604,280,832 bytes here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdzgpdanddiooo39mqzl9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdzgpdanddiooo39mqzl9.png" width="800" height="513"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let’s calculate it ourselves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ip-10-0-40-195 ec2-user]# ps -eo rss,cmd | grep celery | grep -v grep | awk '{sum += $1} END {print sum*1024 " bytes"}'
4608430080 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returning to the issue of there being no information on each process: we can obtain an average value for each one, because we have the metric &lt;code&gt;namedprocess_namegroup_num_procs&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fammsnjtn6rghly0r5aa6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fammsnjtn6rghly0r5aa6.png" width="800" height="513"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let’s check again on the node itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ip-10-0-40-195 ec2-user]# ps aux | grep celery | grep -v grep | wc -l
20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And we can make such a request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sum(namedprocess_namegroup_memory_bytes{memtype="resident", groupname="celery-worker", instance="10.0.45.166:9256"}) by (groupname, instance, pod)
/
sum(namedprocess_namegroup_num_procs{groupname="celery-worker", instance="10.0.45.166:9256"}) by (groupname, instance, pod)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result ~230 MB:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsm230uvpo8ue3mdiyj3t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsm230uvpo8ue3mdiyj3t.png" width="800" height="529"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;As we saw in the &lt;code&gt;ps -eo rss,cmd&lt;/code&gt; output.&lt;/p&gt;

&lt;h3&gt;
  
  
  Name Group Template variables and information about each process
&lt;/h3&gt;

&lt;p&gt;Or, if we really want to see statistics for each process, we can use dynamic names for &lt;code&gt;groupname&lt;/code&gt; with &lt;code&gt;{{.PID}}&lt;/code&gt; - then a separate group will be formed for each process, see &lt;a href="https://github.com/ncabatoff/process-exporter?tab=readme-ov-file#using-a-config-file-group-name" rel="noopener noreferrer"&gt;Using a config file: group name&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
    nameMatchers: 
      # gunicorn (python + uvicorn workers)
      - name: "gunicorn-{{.Comm}}-{{.PID}}"
        exe:
          - python
          - /usr/bin/python
          - /usr/local/bin/python
        cmdline:
          - ".*gunicorn.*"

      # celery worker
      - name: "celery-worker-{{.Comm}}-{{.PID}}"
        exe:
          - python
          - /usr/bin/python
          - /usr/local/bin/python
        cmdline:
          - ".*celery.*worker.*"

      # livekit agent
      - name: "livekit-agent-{{.Comm}}-{{.PID}}"
        exe:
          - python
          - /usr/bin/python
          - /usr/local/bin/python
        cmdline:
          - ".*livekit_agent.*"

      # livekit multiprocessing helpers
      - name: "livekit-multiproc-{{.Comm}}-{{.PID}}"
        exe:
          - python
          - /usr/bin/python
          - /usr/local/bin/python
        cmdline:
          - ".*multiprocessing.*"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As a result, we have the following groups:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feqi2yynkyk5vrxouomx0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feqi2yynkyk5vrxouomx0.png" width="800" height="762"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But this option is only OK if you need to debug something and disable it, because it will lead to a &lt;a href="https://docs.victoriametrics.com/victorialogs/keyconcepts/#high-cardinality" rel="noopener noreferrer"&gt;High cardinality issue&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The result of our debate
&lt;/h3&gt;

&lt;p&gt;Actually, what we needed to find out was whether memory was “leaking” in a single process, or whether multiple processes were simply being created in a single Pod.&lt;/p&gt;

&lt;p&gt;To do this, Grafana created a graph with the following query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sum(namedprocess_namegroup_memory_bytes{memtype="resident", groupname=~"livekit-multiproc-.*"}) by (groupname, instance)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjwrt555lyxdx7p6stqyy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjwrt555lyxdx7p6stqyy.png" width="800" height="526"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We added graphs with Livekit metrics  —  &lt;code&gt;lk_agents_active_job_count&lt;/code&gt; and &lt;code&gt;lk_agents_child_process_count&lt;/code&gt;, and separately - a graph from VictoriaLogs, where we display the number of API requests for each user by the field &lt;code&gt;token_email&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace: "prod-backend-api-ns" "GET /cortex/livekit-token" | unpack_json fields (token_email) | stats by (token_email) count()
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flm2ptgnl4tum2xb8vqli.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flm2ptgnl4tum2xb8vqli.png" width="800" height="526"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And as a result, we have the following picture:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F77nfqzrzmy3kx46starp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F77nfqzrzmy3kx46starp.png" width="800" height="828"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here we see that the same user starts making a bunch of requests to connect to Livekit, which creates a bunch of processes in Livekit Pod (a new Livekit Job for each request), and as a result, the total amount of memory in the Pod goes through the roof, because 40 processes at ~380 MB each is ~15 gigabytes of memory.&lt;/p&gt;

&lt;p&gt;However, in each specific process, memory is maintained at a level of 300–400 megabytes.&lt;/p&gt;

&lt;p&gt;It remains to figure out why the processes are crashing, but that’s a task for the developers.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;/em&gt; &lt;a href="https://rtfm.co.ua/en/kubernetes-monitoring-processes-with-process-exporter/" rel="noopener noreferrer"&gt;&lt;em&gt;RTFM: Linux, DevOps, and system administration&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Terraform: AWS EKS Terraform module update from version 20.x to version 21.x</title>
      <dc:creator>Arseny Zinchenko</dc:creator>
      <pubDate>Thu, 18 Sep 2025 10:02:45 +0000</pubDate>
      <link>https://dev.to/setevoy/terraform-aws-eks-terraform-module-update-from-version-20x-to-version-21x-52im</link>
      <guid>https://dev.to/setevoy/terraform-aws-eks-terraform-module-update-from-version-20x-to-version-21x-52im</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frdcgbe0zxi1aeku3sf31.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frdcgbe0zxi1aeku3sf31.png" width="480" height="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;AWS EKS Terraform module version &lt;a href="https://github.com/terraform-aws-modules/terraform-aws-eks/releases/tag/v21.0.0" rel="noopener noreferrer"&gt;v21.0.0&lt;/a&gt; added support for the &lt;a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/version-6-upgrade" rel="noopener noreferrer"&gt;AWS Provider Version 6&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Documentation — &lt;a href="https://github.com/terraform-aws-modules/terraform-aws-eks/blob/master/docs/UPGRADE-21.0.md" rel="noopener noreferrer"&gt;here&amp;gt;&amp;gt;&amp;gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The main changes in the AWS EKS module are the replacement of IRSA with EKS Pod Identity for the Karpenter sub-module:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Native support for IAM roles for service accounts (IRSA) has been removed; EKS Pod Identity is now enabled by default&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Also, “&lt;em&gt;The &lt;code&gt;aws-auth&lt;/code&gt; sub-module has been removed&lt;/em&gt;”, but I personally removed it a long time ago.&lt;/p&gt;

&lt;p&gt;Some variables have also been renamed.&lt;/p&gt;

&lt;p&gt;I wrote about upgrading from version 19 to 20 in &lt;a href="https://rtfm.co.ua/en/terraform-eks-and-karpenter-upgrade-the-module-version-from-19-21-to-20-0/" rel="noopener noreferrer"&gt;Terraform: EKS and Karpenter — upgrade module version from 19.21 to 20.0&lt;/a&gt;, and this time we will follow the same path — change the module versions and see what breaks.&lt;/p&gt;

&lt;p&gt;I have a separate “Testing” environment for this, which I first roll out with the current versions of modules/providers, then update the code, deploy the upgrade, and when everything is fixed, I upgrade EKS Production (because we have one cluster on dev/staging/prod).&lt;/p&gt;

&lt;p&gt;In Karpenter’s own Helm chart, there seem to be no significant changes, although &lt;a href="https://github.com/aws/karpenter-provider-aws/releases/tag/v1.6.0" rel="noopener noreferrer"&gt;version 1.6&lt;/a&gt; has already been released. You can update it at the same time, but that’s for another time.&lt;/p&gt;

&lt;p&gt;Overall, the upgrade went smoothly, but there were two issues that required some debugging: a problem with the EC2 metadata for AWS Load Balancer Controller during the upgrade, and a problem with EKS Add-ons when creating a new cluster with AWS EKS Terraform module v21.x.&lt;/p&gt;

&lt;h3&gt;
  
  
  Upgrade AWS EKS Terraform module
&lt;/h3&gt;

&lt;h3&gt;
  
  
  Upgrade AWS Provider Version 6
&lt;/h3&gt;

&lt;p&gt;First, change the AWS Provider version — finally, because the open pool requests from &lt;a href="https://rtfm.co.ua/en/renovate-github-and-helm-charts-versions-management/" rel="noopener noreferrer"&gt;Renovate&lt;/a&gt; were annoying, and I couldn’t close them.&lt;/p&gt;

&lt;p&gt;It’s simple — just change the version to 6:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
  required_providers {
    aws = {
      source = "hashicorp/aws"
      version = "~&amp;gt; 6.0"
    }
  }
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use the &lt;a href="https://developer.hashicorp.com/terraform/language/expressions/version-constraints" rel="noopener noreferrer"&gt;pessimistic constraint&lt;/a&gt; operator to allow upgrades of all minor versions.&lt;/p&gt;

&lt;p&gt;This will be considered both by Renovate, and when executing &lt;code&gt;terraform init -upgrade&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Upgrade &lt;code&gt;terraform-aws-modules/eks/aws&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Let’s upgrade the EKS module version — change 20 to 21, also with the “&lt;code&gt;~&amp;gt;&lt;/code&gt;":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
module "eks" {
  source = "terraform-aws-modules/eks/aws"
  version = "~&amp;gt; v21.0"
  ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And Karpenter too, I have it as a separate module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;module "karpenter" {
  source = "terraform-aws-modules/eks/aws//modules/karpenter"
  version = "~&amp;gt; v21.0"
  ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;terraform init&lt;/code&gt;, and get the " &lt;strong&gt;&lt;em&gt;does not match configured version constraint&lt;/em&gt;&lt;/strong&gt;" error, I've already described it in the &lt;a href="https://rtfm.co.ua/en/?p=32870" rel="noopener noreferrer"&gt;Terraform: “no available releases match the given constraints&lt;/a&gt; post:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ terraform init
...
registry.terraform.io/hashicorp/aws 5.100.0 does not match configured version constraint &amp;gt;= 4.0.0, &amp;gt;= 4.36.0, &amp;gt;= 4.47.0, &amp;gt;= 5.0.0, ~&amp;gt; 5.14, &amp;gt;= 6.0.0
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;.terraform.lock.hcl&lt;/code&gt; still contains the old version of the AWS provider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ cat envs/test-1-33/.terraform.lock.hcl | grep -A 5 5.100
  version = "5.100.0"
  constraints = "&amp;gt;= 4.0.0, &amp;gt;= 4.33.0, &amp;gt;= 4.36.0, &amp;gt;= 4.47.0, &amp;gt;= 5.0.0, ~&amp;gt; 5.14, &amp;gt;= 5.95.0"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can drop the file and run &lt;code&gt;terraform init&lt;/code&gt; again, or you can run &lt;code&gt;terraform init -upgrade&lt;/code&gt; to pull all upgrades at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ terraform init -upgrade
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check &lt;code&gt;.terraform.lock.hcl&lt;/code&gt; again - now everything is OK:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git diff .terraform.lock.hcl
diff --git a/terraform/envs/test-1-33/.terraform.lock.hcl b/terraform/envs/test-1-33/.terraform.lock.hcl
index bd44714..cb2eace 100644
--- a/terraform/envs/test-1-33/.terraform.lock.hcl
+++ b/terraform/envs/test-1-33/.terraform.lock.hcl
@@ -24,98 +24,85 @@ provider "registry.terraform.io/alekc/kubectl" {
 }

 provider "registry.terraform.io/hashicorp/aws" {
- version = "5.100.0"
- constraints = "&amp;gt;= 4.0.0, &amp;gt;= 4.33.0, &amp;gt;= 4.36.0, &amp;gt;= 4.47.0, &amp;gt;= 5.0.0, ~&amp;gt; 5.14, &amp;gt;= 5.95.0"
+ version = "6.7.0"
+ constraints = "&amp;gt;= 4.0.0, &amp;gt;= 4.36.0, &amp;gt;= 4.47.0, &amp;gt;= 5.0.0, &amp;gt;= 6.0.0, ~&amp;gt; 6.0"
   hashes = [
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let’s run &lt;code&gt;terraform plan&lt;/code&gt; and see what breaks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Renamed variables в &lt;code&gt;terraform-aws-modules/eks/aws&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The first, as expected, were errors about missing variables, because they had been renamed in the module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ terraform plan -var-file=test-1-33.tfvars
...
│ Error: Unsupported argument
│ 
│ on ../../modules/atlas-eks/eks.tf line 34, in module "eks":
│ 34: cluster_name = "${var.env_name}-cluster"
│ 
│ An argument named "cluster_name" is not expected here.
╵
╷
│ Error: Unsupported argument
│ 
│ on ../../modules/atlas-eks/eks.tf line 38, in module "eks":
│ 38: cluster_version = var.eks_version
│ 
│ An argument named "cluster_version" is not expected here.
╵
╷
│ Error: Unsupported argument
│ 
│ on ../../modules/atlas-eks/eks.tf line 42, in module "eks":
│ 42: cluster_endpoint_public_access = var.eks_params.cluster_endpoint_public_access
│ 
│ An argument named "cluster_endpoint_public_access" is not expected here.
╵
╷
│ Error: Unsupported argument
│ 
│ on ../../modules/atlas-eks/eks.tf line 46, in module "eks":
│ 46: cluster_enabled_log_types = var.eks_params.cluster_enabled_log_types
│ 
│ An argument named "cluster_enabled_log_types" is not expected here.
╵
╷
│ Error: Unsupported argument
│ 
│ on ../../modules/atlas-eks/eks.tf line 50, in module "eks":
│ 50: cluster_addons = {
│ 
│ An argument named "cluster_addons" is not expected here.
╵
╷
│ Error: Unsupported argument
│ 
│ on ../../modules/atlas-eks/eks.tf line 148, in module "eks":
│ 148: cluster_security_group_name = "${var.env_name}-cluster-sg"
│ 
│ An argument named "cluster_security_group_name" is not expected here.
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let’s go to the upgrade documentation and find out what the variables are now called:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cluster_name&lt;/code&gt; =&amp;gt; &lt;code&gt;name&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cluster_version&lt;/code&gt; =&amp;gt; &lt;code&gt;kubernetes_version&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cluster_endpoint_public_access&lt;/code&gt; =&amp;gt; &lt;code&gt;endpoint_public_access&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cluster_enabled_log_types&lt;/code&gt; =&amp;gt; &lt;code&gt;enabled_log_types&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cluster_addons&lt;/code&gt; -&amp;gt; &lt;code&gt;addons&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cluster_security_group_name&lt;/code&gt; -&amp;gt; &lt;code&gt;security_group_name&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Although, in my opinion, the prefix &lt;code&gt;cluster_*&lt;/code&gt; would have been better, because we have &lt;code&gt;node_security_group_name&lt;/code&gt;, and there was &lt;code&gt;cluster_security_group_name&lt;/code&gt; - it is clear which parameter is for what.&lt;/p&gt;

&lt;p&gt;And now there is &lt;code&gt;node_security_group_name&lt;/code&gt; and "some" &lt;code&gt;security_group_name&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Removed variables в terraform-aws-&lt;code&gt;modules/eks/aws//modules/karpenter&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;OK, edit the variable names in the main module code, run &lt;code&gt;terraform plan&lt;/code&gt; again - now we have errors for changes in the &lt;a href="https://registry.terraform.io/modules/terraform-aws-modules/eks/aws/latest/submodules/karpenter" rel="noopener noreferrer"&gt;karpenter&lt;/a&gt; module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
 Error: Unsupported argument
│ 
│ on ../../modules/atlas-eks/karpenter.tf line 7, in module "karpenter":
│ 7: irsa_oidc_provider_arn = module.eks.oidc_provider_arn
│ 
│ An argument named "irsa_oidc_provider_arn" is not expected here.
╵
╷
│ Error: Unsupported argument
│ 
│ on ../../modules/atlas-eks/karpenter.tf line 8, in module "karpenter":
│ 8: irsa_namespace_service_accounts = ["karpenter:karpenter"]
│ 
│ An argument named "irsa_namespace_service_accounts" is not expected here.
╵
╷
│ Error: Unsupported argument
│ 
│ on ../../modules/atlas-eks/karpenter.tf line 14, in module "karpenter":
│ 14: enable_irsa = true
│ 
│ An argument named "enable_irsa" is not expected here.
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They were removed because IRSA no longer exists — an EKS Pod Identity will now be created for Karpenter, see &lt;code&gt;[main.tf#L92&lt;/code&gt;](&lt;a href="https://github.com/terraform-aws-modules/terraform-aws-eks/blob/master/modules/karpenter/main.tf#L92" rel="noopener noreferrer"&gt;https://github.com/terraform-aws-modules/terraform-aws-eks/blob/master/modules/karpenter/main.tf#L92&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;I wrote about EKS Pod Identities in &lt;a href="https://rtfm.co.ua/en/aws-eks-pod-identities-a-replacement-for-irsa-simplifying-iam-access-management/" rel="noopener noreferrer"&gt;AWS: EKS Pod Identities — a replacement for IRSA? Simplifying IAM access management&lt;/a&gt; and in &lt;a href="https://rtfm.co.ua/en/terraform-managing-eks-access-entries-and-eks-pod-identities/" rel="noopener noreferrer"&gt;Terraform: managing EKS Access Entries and EKS Pod Identities&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Let’s remove them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
  #irsa_oidc_provider_arn = module.eks.oidc_provider_arn
  #irsa_namespace_service_accounts = ["karpenter:karpenter"]
  #enable_irsa = true
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;terraform plan&lt;/code&gt; again.&lt;/p&gt;

&lt;h3&gt;
  
  
  Important: Karpenter’s EKS Identity Provider Namespace
&lt;/h3&gt;

&lt;p&gt;And here is an important point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
  # module.atlas_eks.module.karpenter.aws_eks_pod_identity_association.karpenter[0] will be created
  + resource "aws_eks_pod_identity_association" "karpenter" {
      ...
      + namespace = "kube-system"
      + region = "us-east-1"
      + role_arn = "arn:aws:iam::492***148:role/KarpenterIRSA-atlas-eks-test-1-33-cluster"
      + service_account = "karpenter"
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;eks_pod_identity_association&lt;/code&gt; will be created for the Kubernetes Namespace &lt;code&gt;"kube-system"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you have Karpenter running in a different namespace, you need to specify it explicitly when calling the module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
module "karpenter" {
  source = "terraform-aws-modules/eks/aws//modules/karpenter"
  version = "~&amp;gt; v21.0"

  cluster_name = module.eks.cluster_name
  namespace = "karpenter"
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Otherwise, Karpenter will broke, and the WorkerNode Group upgrade will fail because a Node will wait for the Karpenter, which will be in the &lt;code&gt;CrashLoopbackoff&lt;/code&gt;, and the Group upgrade will fail.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;eks_managed_node_groups&lt;/code&gt;: attribute "taints": map of object required
&lt;/h3&gt;

&lt;p&gt;Now there is an error with node group tags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
│ The given value is not suitable for module.atlas_eks.module.eks.var.eks_managed_node_groups declared at .terraform/modules/atlas_eks.eks/variables.tf:1205,1-35: element "test-1-33-default": attribute "taints": map of object required.
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why? Because:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Variable definitions now contain detailed object types in place of the previously used any type.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;See &lt;a href="https://github.com/terraform-aws-modules/terraform-aws-eks/compare/v20.37.2...v21.0.0#diff-aaea88c5bda7b25333fb85570ac1dd5167512fa91699dbedb738d180b2262b41L457" rel="noopener noreferrer"&gt;diff 20 vs 21&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2uzzk9p38z5ifs99qhe0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2uzzk9p38z5ifs99qhe0.png" width="800" height="76"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So now it should be map(object):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
  type = map(object({
    key = string
    value = optional(string)
    effect = string
  }))
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And I have &lt;code&gt;taints&lt;/code&gt; currently been passed from a variable with an object &lt;code&gt;set(map(string))&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
variable "eks_managed_node_group_params" {
  description = "EKS Managed NodeGroups setting, one item in the map() per each dedicated NodeGroup"
  type = map(object({
    min_size = number
    max_size = number
    desired_size = number
    instance_types = list(string)
    capacity_type = string
    taints = set(map(string))
    max_unavailable_percentage = number
  }))
}
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the following values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
eks_managed_node_group_params = {
  default_group = {
    min_size = 1
    max_size = 1
    desired_size = 1
    instance_types = ["t3.medium"]
    capacity_type = "ON_DEMAND"
    taints = [
      {
        key = "CriticalAddonsOnly"
        value = "true"
        effect = "NO_SCHEDULE"
      },
      {
        key = "CriticalAddonsOnly"
        value = "true"
        effect = "NO_EXECUTE"
      }
    ]
    max_unavailable_percentage = 100
  }
}
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So what needs to be done is to change the declaration of the variable in my code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
variable "eks_managed_node_group_params" {
  description = "EKS Managed NodeGroups setting, one item in the map() per each dedicated NodeGroup"
  type = map(object({
    min_size = number
    max_size = number
    desired_size = number
    instance_types = list(string)
    capacity_type = string
    #taints = set(map(string))
    taints = optional(map(object({
      key = string
      value = optional(string)
      effect = string
    })))
    max_unavailable_percentage = number
  }))
}
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And update the values  —  add keys for &lt;code&gt;map{}&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
eks_managed_node_group_params = {
  default_group = {
    min_size = 1
    max_size = 1
    desired_size = 1
    instance_types = ["t3.medium"]
    capacity_type = "ON_DEMAND"
    # taints = [
    # {
    # key = "CriticalAddonsOnly"
    # value = "true"
    # effect = "NO_SCHEDULE"
    # },
    # {
    # key = "CriticalAddonsOnly"
    # value = "true"
    # effect = "NO_EXECUTE"
    # }
    # ]
      taints = {
        critical_no_sched = {
          key = "CriticalAddonsOnly"
          value = "true"
          effect = "NO_SCHEDULE"
        },
        critical_no_exec = {
          key = "CriticalAddonsOnly"
          value = "true"
          effect = "NO_EXECUTE"
        }
      }
    max_unavailable_percentage = 100
  }
}
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;terraform plan&lt;/code&gt; again, and now everything works without errors.&lt;/p&gt;

&lt;p&gt;Let’s deploy the updates.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deploying changes
&lt;/h3&gt;

&lt;p&gt;Run &lt;code&gt;terraform apply&lt;/code&gt;, and now we have a new resource with EKS Pod Identity Association for Karpenter - &lt;code&gt;module.atlas_eks.module.karpenter.aws_eks_pod_identity_association.karpenter&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Froqxc8x4s1mn1o4p6vc1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Froqxc8x4s1mn1o4p6vc1.png" width="800" height="291"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Which wasn’t here in the old cluster with v20.&lt;/p&gt;

&lt;h3&gt;
  
  
  ALB Controller error: “failed to fetch VPC ID from instance metadata”
&lt;/h3&gt;

&lt;p&gt;There was also a problem with AWS Load Balancer Controller, because after the upgrade it could not connect to IMDS, probably due to switching to v2, see &lt;a href="https://rtfm.co.ua/en/aws-security-instance-metadata-service-v1-vs-imds-v2-kubernetes-pod-and-docker-containers/" rel="noopener noreferrer"&gt;AWS: Instance Metadata Service v1 vs IMDS v2 and working with Kubernetes Pod and Docker containers&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
{"level":"error","ts":"2025-08-06T07:25:40Z"," logger":"setup","msg":"unable to initialize AWS cloud","error":"failed to get VPC ID: failed to fetch VPC ID from instance metadata: error in fetching vpc id through ec2 metadata: get mac metadata: operation error ec2imds: GetMetadata, canceled, context deadline exceeded"}
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Actually, we can just pass the parameters explicitly; see the documentation &lt;a href="https://github.com/kubernetes-sigs/aws-load-balancer-controller/blob/de50bdd80b227fb2ed940b30e33c224065d8c035/docs/deploy/installation.md#using-the-amazon-ec2-instance-metadata-server-version-2-imdsv2" rel="noopener noreferrer"&gt;Using the Amazon EC2 instance metadata server version 2 (IMDSv2)&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Note the &lt;code&gt;--aws-vpc-tag-key&lt;/code&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;optional flag — aws-vpc-tag-key if you have a different key for the tag other than “Name”&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;First, let’s try setting the parameters manually to check that it works:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxxmvawiae32km4z93vph.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxxmvawiae32km4z93vph.png" width="430" height="129"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Everything is working now.&lt;/p&gt;

&lt;p&gt;Now the parameters for the Helm chart, see its &lt;a href="https://github.com/kubernetes-sigs/aws-load-balancer-controller/blob/main/helm/aws-load-balancer-controller/values.yaml#L163" rel="noopener noreferrer"&gt;values.yaml#L163&lt;/a&gt; — my controllers are installed from &lt;a href="https://github.com/aws-ia/terraform-aws-eks-blueprints-addons" rel="noopener noreferrer"&gt;aws-ia/eks-blueprints-addons/aws&lt;/a&gt; in Terraform when creating the cluster, so set here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
    values = [
      &amp;lt;&amp;lt;-EOT
        replicaCount: 1
        region: ${var.aws_region}
        vpcId: ${var.vpc_id}
        tolerations:
        - key: CriticalAddonsOnly
          operator: Exists
      EOT
    ]
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start the deployment:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl2l3gh46n3out0854m1a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl2l3gh46n3out0854m1a.png" width="516" height="220"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Everything works.&lt;/p&gt;

&lt;h3&gt;
  
  
  Issue: Node Group Status CREATE_FAILED
&lt;/h3&gt;

&lt;p&gt;Here I will describe a problem that arose only when creating a new EKS cluster with module v21 — upgrading an existing cluster proceeds without these issues.&lt;/p&gt;

&lt;p&gt;Actually, here’s the problem: the cluster was created, everything seems OK, but it hangs for a long time on creating the Node Group, and then crashes with the error “ &lt;strong&gt;&lt;em&gt;unexpected state ‘CREATE_FAILED’&lt;/em&gt;&lt;/strong&gt; ”:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
╷
│ Error: waiting for EKS Node Group (atlas-eks-test-1-33-cluster:test-1-33-default-20250801112636765600000014) create: unexpected state 'CREATE_FAILED', wanted target 'ACTIVE'. last error: i-03f2c73c7211880f7: NodeCreationFailure: Unhealthy nodes in the kubernetes cluster
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgdqc5cnix06dkgssybly.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgdqc5cnix06dkgssybly.png" width="800" height="225"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Although there is an EC2 Auto Scaling Group created, and it has an EC2 up and running.&lt;/p&gt;

&lt;p&gt;Why?&lt;/p&gt;

&lt;p&gt;So, the problem is that WorkerNode has been created but cannot connect to Kubernetes.&lt;/p&gt;

&lt;p&gt;The first thing that comes to mind is to check the Security Group, but everything appears to be correct here — all the rules are correct. I compared it with the current EKS cluster, which was created with AWS EKS Terraform module v20.x — everything is the same.&lt;/p&gt;

&lt;p&gt;Problem with IAM? EC2 doesn’t have permissions to access the cluster? Again, compare with the old cluster, and everything is OK.&lt;/p&gt;

&lt;h3&gt;
  
  
  “Check the logs, Billy!”
&lt;/h3&gt;

&lt;p&gt;The funny thing is that SSH is configured on all my EC2 instances, but only for nodes created with Karpenter, as I wrote in &lt;a href="https://rtfm.co.ua/en/aws-karpenter-and-ssh-for-kubernetes-workernodes/" rel="noopener noreferrer"&gt;AWS: Karpenter and SSH for Kubernetes WorkerNodes&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The current problem arose in the “default” NodeGroup, where various controllers are launched.&lt;/p&gt;

&lt;p&gt;So, let’s connect via the AWS Console and select Connect:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgmc3pp27b2b2c20p3ciz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgmc3pp27b2b2c20p3ciz.png" width="800" height="42"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then, in &lt;em&gt;EC2 Instance Connect&lt;/em&gt;, select “&lt;em&gt;Connect using a Private IP&lt;/em&gt;” and select an existing &lt;a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/connect-using-eice.html" rel="noopener noreferrer"&gt;EC2 Instance Connect Endpoint&lt;/a&gt; or quickly create a new one.&lt;/p&gt;

&lt;p&gt;Set the username — for Amazon Linux, it is &lt;code&gt;ec2-user&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5vwsv4cypngch5niqoww.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5vwsv4cypngch5niqoww.png" width="800" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And let’s look at the logs:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsaj5nece568i95b36p3d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsaj5nece568i95b36p3d.png" width="800" height="72"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  “Container runtime network not ready — cni plugin not initialized”
&lt;/h3&gt;

&lt;p&gt;Actually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Aug 01 13:26:04 ip-10-0-48-198.ec2.internal kubelet[1619]: E0801 13:26:04.989799 1619 kubelet.go:3126] "Container runtime network not ready" networkReady="NetworkReady=false reason:NetworkPluginNotReady message:Network plugin returns error: cni plugin not initialized"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wow…&lt;/p&gt;

&lt;p&gt;Okay, what’s the situation with VPC CNI?&lt;/p&gt;

&lt;p&gt;Let’s go check out EKS Add-ons, and…&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe3np5dz2fi9a9cfq5dwb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe3np5dz2fi9a9cfq5dwb.png" width="800" height="295"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It’s completely empty.&lt;/p&gt;

&lt;p&gt;Let’s look at the log &lt;code&gt;terraform apply&lt;/code&gt; - and we see "&lt;em&gt;Read complete&lt;/em&gt;", but there is no "&lt;em&gt;Creating...&lt;/em&gt;":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
module.atlas_eks.module.eks.data.aws_eks_addon_version.this["vpc-cni"]: Read complete after 0s [id=vpc-cni]
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let’s check if there are any containers on the node — maybe there are some errors?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqeor4gv4e3lat5xd80qy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqeor4gv4e3lat5xd80qy.png" width="559" height="103"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Wow, once again…&lt;/p&gt;

&lt;p&gt;Nothing at all.&lt;/p&gt;

&lt;p&gt;Even then, I went back to GitHub Issues and searched for “&lt;em&gt;addon&lt;/em&gt;” and found this issue: &lt;a href="https://github.com/terraform-aws-modules/terraform-aws-eks/issues/3446" rel="noopener noreferrer"&gt;Managed EKS Node Groups boot without CNI, but addon is added after node group&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Actually, yes  —  the problem arose due to the absence of the &lt;code&gt;before_compute&lt;/code&gt; parameter.&lt;/p&gt;

&lt;p&gt;Although it’s a little strange, because it was &lt;a href="https://github.com/terraform-aws-modules/terraform-aws-eks/releases/tag/v19.9.0" rel="noopener noreferrer"&gt;added in version v19.9&lt;/a&gt;, the last time I deployed a cluster from scratch was with v20, and this problem did not occur.&lt;/p&gt;

&lt;p&gt;Even more, when I created the Testing cluster from the master branch, where none of the updates described here have been applied, and module version v20 is still used — everything is working without any problems.&lt;/p&gt;

&lt;p&gt;And in &lt;a href="https://github.com/terraform-aws-modules/terraform-aws-eks/compare/v20.37.2...v21.0.0#diff-aaea88c5bda7b25333fb85570ac1dd5167512fa91699dbedb738d180b2262b41L457" rel="noopener noreferrer"&gt;diff 20 vs 21&lt;/a&gt; I don’t see any significant changes related to &lt;code&gt;before_compute&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;However, since this only applies to creating a new cluster, we do not need to add before_compute when simply upgrading. But if you do add it, the add-ons will be recreated.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;before_compute itself&lt;/code&gt; was added to allow specifying which addons to create before WorkerNodes and which after. See &lt;a href="https://github.com/terraform-aws-modules/terraform-aws-eks/blob/master/main.tf#L797" rel="noopener noreferrer"&gt;&lt;code&gt;main.tf#L797&lt;/code&gt;&lt;/a&gt; and comments to &lt;a href="https://github.com/terraform-aws-modules/terraform-aws-eks/pull/2478" rel="noopener noreferrer"&gt;PR #2478&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Add as in the examples &lt;a href="https://github.com/terraform-aws-modules/terraform-aws-eks?tab=readme-ov-file#eks-managed-node-group" rel="noopener noreferrer"&gt;EKS Managed Node Group&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
    vpc-cni = {
      addon_version = var.eks_addon_versions.vpc_cni
      before_compute = true
      configuration_values = jsonencode({
        env = {
          ENABLE_PREFIX_DELEGATION = "true"
          WARM_PREFIX_TARGET = "1"
          AWS_VPC_K8S_CNI_EXTERNALSNAT = "true"
        }
      })
    }
    aws-ebs-csi-driver = {
      addon_version = var.eks_addon_versions.aws_ebs_csi_driver
      service_account_role_arn = module.ebs_csi_irsa_role.iam_role_arn
    }
    eks-pod-identity-agent = {
      addon_version = var.eks_addon_versions.eks_pod_identity_agent
      before_compute = true
    }
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;terraform apply&lt;/code&gt; again, and here it is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
module.atlas_eks.module.eks.aws_eks_addon.before_compute["vpc-cni"]: Creating...
...
module.atlas_eks.module.eks.aws_eks_addon.before_compute["vpc-cni"]: Creation complete after 46s [id=atlas-eks-test-1-33-cluster:vpc-cni]
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in the AWS Console:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ful51xq6b3hst723mv0ul.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ful51xq6b3hst723mv0ul.png" width="800" height="435"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;NodeGroup created without errors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
module.atlas_eks.module.eks.module.eks_managed_node_group["test-1-33-default"].aws_eks_node_group.this[0]: Still creating... [01m40s elapsed]
module.atlas_eks.module.eks.module.eks_managed_node_group["test-1-33-default"].aws_eks_node_group.this[0]: Creation complete after 1m49s [id=atlas-eks-test-1-33-cluster:test-1-33-default-20250801142042855800000003]
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Done.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;/em&gt; &lt;a href="https://rtfm.co.ua/en/terraform-aws-eks-terraform-module-update-from-version-20-x-to-version-21/" rel="noopener noreferrer"&gt;&lt;em&gt;RTFM: Linux, DevOps, and system administration&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>terraform</category>
      <category>kubernetes</category>
      <category>devops</category>
      <category>todayilearned</category>
    </item>
    <item>
      <title>Kubernetes: PVC in a StatefulSet, and the “Forbidden updates to statefulset spec” error</title>
      <dc:creator>Arseny Zinchenko</dc:creator>
      <pubDate>Wed, 17 Sep 2025 12:36:49 +0000</pubDate>
      <link>https://dev.to/setevoy/kubernetes-pvc-in-a-statefulset-and-the-forbidden-updates-to-statefulset-spec-error-5007</link>
      <guid>https://dev.to/setevoy/kubernetes-pvc-in-a-statefulset-and-the-forbidden-updates-to-statefulset-spec-error-5007</guid>
      <description>&lt;h3&gt;
  
  
  Kubernetes: PVC in StatefulSet, and the “Forbidden updates to statefulset spec” error
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flijo0rv8bgvtv6m6x4z6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flijo0rv8bgvtv6m6x4z6.png" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We have a &lt;a href="https://github.com/VictoriaMetrics/helm-charts/tree/master/charts/victoria-logs-single" rel="noopener noreferrer"&gt;VictoriaLogs&lt;/a&gt; Helm chart with a PVC size of 30 GB, which is no longer enough for us, and we need to increase it.&lt;/p&gt;

&lt;p&gt;But the problem is that &lt;code&gt;.spec.volumeClaimTemplates[*].spec.resources.requests.storage&lt;/code&gt; in STS is immutable, that is, we can't just change the size through &lt;code&gt;values.yaml&lt;/code&gt; file, because it will lead to the error &lt;strong&gt;&lt;em&gt;"Forbidden: updates to statefulset spec for fields other than 'replicas', 'ordinals', 'template', 'updateStrategy', 'revisionHistoryLimit', 'persistentVolumeClaimRetentionPolicy' and 'minReadySeconds' are forbidden&lt;/em&gt;&lt;/strong&gt;".&lt;/p&gt;

&lt;p&gt;The chart values now look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;victoria-logs-single:
  server:
    persistentVolume:
      enabled: true
      storageClassName: gp2-retain
      size: 30Gi
    retentionPeriod: 7d
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And with the default type of StatefulSet in the chart, the &lt;code&gt;volumeClaimTemplates&lt;/code&gt; is used to create PVCs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...  
volumeClaimTemplates:
    - apiVersion: v1
      kind: PersistentVolumeClaim
      metadata:
        name: server-volume
        ...
      spec:
        ...
        resources:
          requests:
            storage: {{ $app.persistentVolume.size }}
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If instead of STS there was a Deployment type, then in the VictoriaLogs chart this would lead to the creation of a separate PVC  —  see the &lt;a href="https://github.com/VictoriaMetrics/helm-charts/blob/master/charts/victoria-logs-single/templates/pvc.yaml#L3C1-L3C81" rel="noopener noreferrer"&gt;&lt;code&gt;pvc.yaml&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;You could simply create a separate PVC yourself and connect it through the &lt;a href="https://github.com/VictoriaMetrics/helm-charts/blob/master/charts/victoria-logs-single/values.yaml#L167" rel="noopener noreferrer"&gt;&lt;code&gt;existingClaim&lt;/code&gt;&lt;/a&gt; value, but you already have a PersistentVolume, and you don't want to create a new one and migrate data (although you can if you need to, see &lt;a href="https://rtfm.co.ua/en/victoriametrics-migrating-vmsingle-and-victorialogs-data-between-kubernetes-cluster/" rel="noopener noreferrer"&gt;VictoriaMetrics: migrating VMSingle and VictoriaLogs data between Kubernetes clusters&lt;/a&gt;, but there will be a down time), so let's see how we can solve this differently - without deleting Pods and without stopping the service.&lt;/p&gt;

&lt;h3&gt;
  
  
  storageClassName and AllowVolumeExpansion
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;storageClas&lt;/code&gt; used to create a Persistent Volume must support &lt;code&gt;AllowVolumeExpansion&lt;/code&gt; - see &lt;a href="https://kubernetes.io/docs/concepts/storage/storage-classes/#allow-volume-expansion" rel="noopener noreferrer"&gt;Volume expansion&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk describe storageclass gp2-retain
Name: gp2-retain
...
Provisioner: kubernetes.io/aws-ebs
Parameters: &amp;lt;none&amp;gt;
AllowVolumeExpansion: True
MountOptions: &amp;lt;none&amp;gt;
ReclaimPolicy: Retain
VolumeBindingMode: WaitForFirstConsumer
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create this &lt;code&gt;storageClass&lt;/code&gt; when creating an EKS cluster from a simple manifest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
resource "kubectl_manifest" "storageclass_gp2_retain" {

  yaml_body = &amp;lt;&amp;lt;YAML
    apiVersion: storage.k8s.io/v1
    kind: StorageClass
    metadata:
      name: gp2-retain
    provisioner: kubernetes.io/aws-ebs
    reclaimPolicy: Retain
    allowVolumeExpansion: true
    volumeBindingMode: WaitForFirstConsumer
  YAML
}
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Although there is a dedicated &lt;a href="https://registry.terraform.io/providers/hashicorp/kubernetes/latest/docs/resources/storage_class" rel="noopener noreferrer"&gt;&lt;code&gt;storage_class&lt;/code&gt;&lt;/a&gt; resource for Terraform, and would be better to use it instead for the &lt;code&gt;kubectl_manifest&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And the &lt;code&gt;kubernetes.io/aws-ebs&lt;/code&gt; driver is already deprecated (OMG, &lt;a href="https://aws.amazon.com/blogs/containers/amazon-ebs-csi-driver-is-now-generally-available-in-amazon-eks-add-ons/" rel="noopener noreferrer"&gt;since Kubernetes 1.17&lt;/a&gt;!), it's time to update to &lt;code&gt;ebs.csi.aws.com&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But we’ll fix this later, right now the goal is to simply increase the disk.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reproducing the issue
&lt;/h3&gt;

&lt;p&gt;For the test, let’s write our own STS with &lt;code&gt;volumeClaimTemplates&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: demo-sts
spec:
  serviceName: demo-sts-svc
  replicas: 1
  selector:
    matchLabels:
      app: demo
  template:
    metadata:
      labels:
        app: demo
    spec:
      containers:
        - name: app
          image: busybox
          command: ["sh", "-c", "sleep 3600"]
          volumeMounts:
            - name: data
              mountPath: /data
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: gp2-retain
        resources:
          requests:
            storage: 1Gi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;code&gt;volumeClaimTemplates&lt;/code&gt;, set the &lt;code&gt;storageClassName&lt;/code&gt; and the size to 1 gigabyte.&lt;/p&gt;

&lt;p&gt;Deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk apply -f test-sts-pvc.yaml 
statefulset.apps/demo-sts created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the PVC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
data-demo-sts-0 Bound pvc-31a9a547-7547-4d34-bb2d-2c7015b9e0f3 1Gi RWO gp2-retain &amp;lt;unset&amp;gt; 15s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, if we want to increase the size via &lt;code&gt;volumeClaimTemplates&lt;/code&gt; from 1Gi to 2Gi:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
  volumeClaimTemplates:
    ...
        resources:
          requests:
            storage: 2Gi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then we get an error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk apply -f test-sts-pvc.yaml 
The StatefulSet "demo-sts" is invalid: spec: Forbidden: updates to statefulset spec for fields other than 'replicas', 'ordinals', 'template', 'updateStrategy', 'revisionHistoryLimit', 'persistentVolumeClaimRetentionPolicy' and 'minReadySeconds' are forbidden
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Fix
&lt;/h3&gt;

&lt;p&gt;But we can get around this very easily:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;edit the PVC manually   — set a new size&lt;/li&gt;
&lt;li&gt;delete STS with the &lt;code&gt;--cascade=orphan&lt;/code&gt; - see &lt;a href="https://kubernetes.io/docs/tasks/administer-cluster/use-cascading-deletion/#set-orphan-deletion-policy" rel="noopener noreferrer"&gt;Delete owner objects and orphan dependents&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;create STS again&lt;/li&gt;
&lt;li&gt;…&lt;/li&gt;
&lt;li&gt;profit!&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Let’s try it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Note&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;: before changing disks, don’t forget about backups!&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Edit the PVC manually —  change &lt;code&gt;resources.requests.storage&lt;/code&gt; from 1Gi to 2Gi:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Favh7wrurqnhk8v516xau.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Favh7wrurqnhk8v516xau.png" width="266" height="145"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Check the Events of this PVC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk describe pvc data-demo-sts-0
...
  Normal ExternalExpanding 40s volume_expand CSI migration enabled for kubernetes.io/aws-ebs; waiting for external resizer to expand the pvc
  Normal Resizing 40s external-resizer ebs.csi.aws.com External resizer is resizing volume pvc-31a9a547-7547-4d34-bb2d-2c7015b9e0f3
  Normal FileSystemResizeRequired 35s external-resizer ebs.csi.aws.com Require file system resize of volume on node
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And after a few more seconds, it’s done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
  Normal FileSystemResizeSuccessful 19s kubelet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check &lt;code&gt;CAPACITY&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
data-demo-sts-0 Bound pvc-31a9a547-7547-4d34-bb2d-2c7015b9e0f3 2Gi RWO gp2-retain &amp;lt;unset&amp;gt; 4m7s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;2Gi&lt;/code&gt;, everything is OK.&lt;/p&gt;

&lt;p&gt;And now we also have 2 gigabytes in the Pod itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk exec -ti demo-sts-0 -- df -h /data
Filesystem Size Used Available Use% Mounted on
/dev/nvme7n1 1.9G 24.0K 1.9G 0% /data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But if we try to deploy the changes to &lt;code&gt;volumeClaimTemplates.spec.resources.requests.storage&lt;/code&gt; again, we will still get an error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk apply -f test-sts-pvc.yaml 
The StatefulSet "demo-sts" is invalid: spec: Forbidden: updates to statefulset spec for fields other than 'replicas', 'ordinals', 'template', 'updateStrategy', 'revisionHistoryLimit', 'persistentVolumeClaimRetentionPolicy' and 'minReadySeconds' are forbidden
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So, delete the STS itself, but leave all its dependent objects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl delete statefulset demo-sts --cascade=orphan 
statefulset.apps "demo-sts" deleted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check if the Pod is alive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk get pod
NAME READY STATUS RESTARTS AGE
demo-sts-0 1/1 Running 0 3m13s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And now we just create STS again, with a new value in the &lt;code&gt;volumeClaimTemplates.spec.resources.requests.storage&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk apply -f test-sts-pvc.yaml 
statefulset.apps/demo-sts created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Done.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;/em&gt; &lt;a href="https://rtfm.co.ua/en/kubernetes-pvc-v-statefulset-and-the-forbidden-updates-to-statefulset-spec-error/" rel="noopener noreferrer"&gt;&lt;em&gt;RTFM: Linux, DevOps, and system administration&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>todayilearned</category>
    </item>
    <item>
      <title>Kubernetes: what are the Kubernetes Operator and CustomResourceDefinition</title>
      <dc:creator>Arseny Zinchenko</dc:creator>
      <pubDate>Tue, 16 Sep 2025 07:27:07 +0000</pubDate>
      <link>https://dev.to/setevoy/kubernetes-what-are-the-kubernetes-operator-and-customresourcedefinition-26id</link>
      <guid>https://dev.to/setevoy/kubernetes-what-are-the-kubernetes-operator-and-customresourcedefinition-26id</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flijo0rv8bgvtv6m6x4z6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flijo0rv8bgvtv6m6x4z6.png" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Perhaps everyone has used operators in Kubernetes, for example, &lt;a href="https://github.com/zalando/postgres-operator" rel="noopener noreferrer"&gt;PostgreSQL operator&lt;/a&gt;, &lt;a href="https://docs.victoriametrics.com/operator/" rel="noopener noreferrer"&gt;VictoriaMetrics Operator&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;But what’s going on under the hood? How and to what are CustomResourceDefinition (CRD) applied, and what is an “operator”?&lt;/p&gt;

&lt;p&gt;And finally, what is the difference between a Kubernetes Operator and a Kubernetes Controller?&lt;/p&gt;

&lt;p&gt;In the previous part — &lt;a href="https://rtfm.co.ua/kubernetes-kubernetes-api-api-groups-crd-ta-etcd/" rel="noopener noreferrer"&gt;Kubernetes:&lt;/a&gt;&lt;a href="https://rtfm.co.ua/en/kubernetes-kubernetes-api-api-groups-crds-and-the-etcd/" rel="noopener noreferrer"&gt;Kubernetes APIs, API Groups, CRDs, etcd&lt;/a&gt; — we dug a little deeper into how the Kubernetes API works and what a CRD is, and now we can try to write our own micro-operator, a simple MVP, and use it as an example to understand the details.&lt;/p&gt;

&lt;h3&gt;
  
  
  Contents
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Kubernetes Controller vs Kubernetes Operator&lt;/li&gt;
&lt;li&gt;What is: Kubernetes Controller&lt;/li&gt;
&lt;li&gt;What is: Kubernetes Operator&lt;/li&gt;
&lt;li&gt;Kubernetes Operator frameworks&lt;/li&gt;
&lt;li&gt;Creating a CustomResourceDefinition&lt;/li&gt;
&lt;li&gt;Creating a Kubernetes Operator with Kopf&lt;/li&gt;
&lt;li&gt;Resource templates: Kopf and Kubebuilder&lt;/li&gt;
&lt;li&gt;And what about in real operators?&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Kubernetes Controller vs Kubernetes Operator
&lt;/h3&gt;

&lt;p&gt;So, what is the main difference between Controllers and Operators?&lt;/p&gt;

&lt;h3&gt;
  
  
  What is: Kubernetes Controller
&lt;/h3&gt;

&lt;p&gt;Simply put, a &lt;em&gt;Controller&lt;/em&gt; is just some service that monitors resources in a cluster and brings their state in line with how this state is described in the database  — &lt;code&gt; etcd&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In Kubernetes, we have a set of default controllers — Core Controllers within the &lt;a href="https://kubernetes.io/docs/reference/command-line-tools-reference/kube-controller-manager/" rel="noopener noreferrer"&gt;Kube Controller Manager&lt;/a&gt;, such as the ReplicaSet Controller, which checks the number of pods in the Deployment against the replicas value, or the Deployment Controller, which controls the creation and update of ReplicaSets, or the PersistentVolume Controller and PersistentVolumeClaim Binder for working with disks, etc.&lt;/p&gt;

&lt;p&gt;In addition to these default controllers, you can create your own controller or use an existing one, such as ExternalDNS Controller. These are examples of custom controllers.&lt;/p&gt;

&lt;p&gt;Controllers work in a &lt;strong&gt;&lt;em&gt;control loop&lt;/em&gt;&lt;/strong&gt;  — a cyclic process in which they constantly check the resources assigned to them — either to change existing resources in the system or to respond to the addition of new ones.&lt;/p&gt;

&lt;p&gt;During each check*&lt;em&gt;&lt;em&gt;(reconciliation loop&lt;/em&gt;&lt;/em&gt;*), the Controller compares the &lt;em&gt;current state&lt;/em&gt; of the resource and compares it with the &lt;em&gt;desired state &lt;/em&gt;— that is, the parameters specified in its manifest when the resource was created or updated.&lt;/p&gt;

&lt;p&gt;If the desired state does not correspond to the current state, the controller performs the necessary actions to bring these states into alignment.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is: Kubernetes Operator
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;Kubernetes Operator&lt;/em&gt;, in turn, is a kind of “controller on steroids”: in fact, Operator is a Custom Controller in the sense that it has its own service in the form of a Pod that communicates with the Kubernetes API to receive and update information about resources.&lt;/p&gt;

&lt;p&gt;But if ordinary controllers work with “default” resource types (Pod, Endpoint Slice, Node, PVC), then for Operator we describe our own custom resources using a manifest with Custom Resource.&lt;/p&gt;

&lt;p&gt;And how these resources will look like and what parameters they will have — we set through CustomResourceDefinition which are written to the Kubernetes database and added to the Kubernetes API, and thus the Kubernetes API allows our custom Controller to operate with these resources.&lt;/p&gt;

&lt;p&gt;That is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Controller&lt;/strong&gt; is a component, a service, and &lt;strong&gt;Operator&lt;/strong&gt; is a combination of one or more custom Controllers and corresponding CRDs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Controller&lt;/strong&gt;  — responds to changes in resources, and &lt;strong&gt;Operator&lt;/strong&gt;  — adds new types of resources + controller that controls these resources&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Kubernetes Operator frameworks
&lt;/h3&gt;

&lt;p&gt;There are several solutions that simplify the creation of operators.&lt;/p&gt;

&lt;p&gt;The main ones are &lt;a href="https://book.kubebuilder.io/introduction" rel="noopener noreferrer"&gt;Kubebuilder&lt;/a&gt;, a framework for creating controllers in Go, and &lt;a href="https://kopf.readthedocs.io/en/stable/" rel="noopener noreferrer"&gt;Kopf&lt;/a&gt;, a framework in Python.&lt;/p&gt;

&lt;p&gt;There is also the &lt;a href="https://sdk.operatorframework.io/" rel="noopener noreferrer"&gt;Operator SDK&lt;/a&gt;, which allows you to work with controllers even with Helm, without code.&lt;/p&gt;

&lt;p&gt;At first, I was thinking of doing it in bare Go, without any frameworks, to better understand how everything works under the hood — but this post started to turn into 95% Golang.&lt;/p&gt;

&lt;p&gt;And since the main idea of the post was to show conceptually what a Kubernetes Operator is, what role CustomResourceDefinitions play, and how they interact with each other and allow you to manage resources, I decided to use &lt;a href="https://kopf.readthedocs.io/en/stable/" rel="noopener noreferrer"&gt;Kopf&lt;/a&gt; because it’s very simple and quite suitable for these purposes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating a CustomResourceDefinition
&lt;/h3&gt;

&lt;p&gt;Let’s start with writing the CRD.&lt;/p&gt;

&lt;p&gt;Actually, CustomResourceDefinition is just a description of what fields our custom resource will have so that the controller can use them through the Kubernetes API to create real resources — whether they are some resources in Kubernetes itself, or external ones like AWS Load Balancer or AWS Route 53.&lt;/p&gt;

&lt;p&gt;What we will do: we will write a CRD that will describe the &lt;code&gt;MyApp&lt;/code&gt; resource, and this resource will have fields for the Docker image and a custom field with some text that will then be written to the Kubernetes Pod logs.&lt;/p&gt;

&lt;p&gt;Kubernetes documentation on CRD — &lt;a href="https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/" rel="noopener noreferrer"&gt;Extend the Kubernetes API with CustomResourceDefinitions&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Create the file &lt;code&gt;myapp-crd.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: myapps.demo.rtfm.co.ua
spec:
  group: demo.rtfm.co.ua
  versions:
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                image:
                  type: string
                banner: 
                  type: string
                  description: "Optional banner text for the application"
  scope: Namespaced
  names:
    plural: myapps
    singular: myapp
    kind: MyApp
    shortNames:
      - ma
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;spec.group: demo.rtfm.co.ua&lt;/code&gt;: create a new API Group, all resources of this type will be available at &lt;code&gt;/apis/demo.rtfm.co.ua/...&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;versions&lt;/code&gt;: list of versions of the new resource&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;name.v1&lt;/code&gt;: we will have an only one version&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;served: true&lt;/code&gt;: add a new resource to the Kube API - you can do &lt;code&gt;kubectl get myapp&lt;/code&gt; (&lt;code&gt;GET /apis/demo.rtfm.co.ua/v1/myapps&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;storage: true&lt;/code&gt;: this version will be used for storage in &lt;code&gt;etcd&lt;/code&gt; (if several versions are described, only one should be with &lt;code&gt;storage: true&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;schema&lt;/code&gt;:&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;openAPIV3Schema&lt;/code&gt;: describe the API scheme according to the &lt;a href="https://swagger.io/specification/" rel="noopener noreferrer"&gt;OpenAPI v3&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;type: object&lt;/code&gt;: describe an object with nested fields (key: value)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;properties&lt;/code&gt;: what fields the object will have&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;spec&lt;/code&gt;: what we can use in YAML manifests when creating&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;type: object&lt;/code&gt; - describe the following fields:&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;properties&lt;/code&gt;:&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;image.type: string&lt;/code&gt;: a Docker image&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;banner.type: string&lt;/code&gt;: our custom field through which we will add some entry to the resource logs&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scope: Namespace&lt;/code&gt;d: all resources of this type will exist in a specific Kubernetes Namespace&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;names&lt;/code&gt;:&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;plural: myapps&lt;/code&gt;: the resources will be available through &lt;code&gt;/apis/demo.rtfm.co.ua/v1/namespaces/&amp;lt;ns&amp;gt;/myapps/&lt;/code&gt;, and how we can "access" the resource (&lt;code&gt;kubectl get myapp&lt;/code&gt;), used in RBAC where you need to specify &lt;code&gt;resources:["myapps"]&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;singular: myapp&lt;/code&gt;: alias for convenience&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;shortNames:[ma]&lt;/code&gt;: short alias for convenience&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let’s start Minikube:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ minikube start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the CRD:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk apply -f myapp-crd.yaml 
customresourcedefinition.apiextensions.k8s.io/myapps.demo.rtfm.co.ua created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let’s look at the Groups API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$kubectl api-versions 
...
demo.rtfm.co.ua/v1 
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a new resource in this API Group:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl api-resources --api-group=demo.rtfm.co.ua
NAME SHORTNAMES APIVERSION NAMESPACED KIND
myapps ma demo.rtfm.co.ua/v1 true MyApp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;OK  —  we have created a CRD, and now we can even create a CustomResource (CR).&lt;/p&gt;

&lt;p&gt;Create the file &lt;code&gt;myapp-example-resource.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apiVersion: demo.rtfm.co.ua/v1 # matches the CRD's group and version
kind: MyApp # kind from the CRD's 'spec.names.kind'
metadata:
  name: example-app # name of this custom resource
  namespace: default # namespace (CRD has scope: Namespaced)
spec:
  image: nginx:latest # container image to use (from our schema)
  banner: "This pod was created by MyApp operator 🚀"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk apply -f myapp-example-resource.yaml 
myapp.demo.rtfm.co.ua/example-app created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk get myapp
NAME AGE
example-app 15s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But there are no resources of type Pod  —  because we do not have a controller that will work with this type of resources.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating a Kubernetes Operator with Kopf
&lt;/h3&gt;

&lt;p&gt;So, we will use Kopf to create a Kubernetes Pod, but using our own CRD.&lt;/p&gt;

&lt;p&gt;Create a Python virtual environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python -m venv venv 
$ . ./venv/bin/activate 
(venv)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add dependencies  — &lt;code&gt; requirements.txt&lt;/code&gt; file :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kopf 
kubernetes
PyYAML
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install them  —  with &lt;code&gt;pip&lt;/code&gt; or &lt;code&gt;uv&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ pip install -r requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let’s write the operator code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import os
import kopf
import kubernetes
import yaml

# use kopf to register a handler for the creation of MyApp custom resources
@kopf.on.create('demo.rtfm.co.ua', 'v1', 'myapps')
# this function will be called when a new MyApp resource is created
def create_myapp(spec, name, namespace, logger, **kwargs):
    # get image value from the spec of the CustomResource manifest
    image = spec.get('image')
    if not image:
        raise kopf.PermanentError("Field 'spec.image' must be provided.")

    # get optional banner value from the CR manifest spec
    banner = spec.get('banner')

    # load pod template YAML from file
    path = os.path.join(os.path.dirname( __file__ ), 'pod.yaml')
    with open(path, 'rt') as f:
        pod_template = f.read()

    # render pod YAML with provided values
    pod_yaml = pod_template.format(
        name=f"{name}-pod",
        image=image,
        app_name=name,
    )
    # create Pod difinition from the rendered YAML
    # it uses PyYAML to parse the YAML string into a Python dictionary
    # which can be used by Kubernetes API client
    # it is used to create a Pod object in Kubernetes
    pod_spec = yaml.safe_load(pod_yaml)

    # inject banner as environment variable if provided
    if banner:
        # it is used to add a new environment variable into the container spec
        container = pod_spec['spec']['containers'][0]
        env = container.setdefault('env', [])
        env.append({
            'name': 'BANNER',
            'value': banner
        })

    # create Kubernetes CoreV1 API client
    # used to interact with the Kubernetes API
    api = kubernetes.client.CoreV1Api()

    try:
        # it sends a request to the Kubernetes API to create a new Pod
        # uses 'create_namespaced_pod' method to create the Pod in the specified namespace
        # 'namespace' is the namespace where the Pod will be created
        # 'body' is the Pod specification that was created from the YAML template
        api.create_namespaced_pod(namespace=namespace, body=pod_spec)
        logger.info(f"Pod {name}-pod created.")
    except kubernetes.client.exceptions.ApiException as e:
        logger.error(f"Failed to create pod {name}-pod: {e}")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create a template that will be used by our Operator to create resources:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apiVersion: v1
kind: Pod
metadata:
  name: {name}
  labels:
    app: {app_name}
spec:
  containers:
    - name: {app_name}
      image: {image}
      ports:
        - containerPort: 80
      env:
        - name: BANNER
          value: "" # will be overridden in code if provided
      command: ["/bin/sh", "-c"]
      args:
        - |
          if [-n "$BANNER"]; then
            echo "$BANNER";
          fi
          exec sleep infinity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the operator with &lt;code&gt;kopf run myoperator.py&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;We already have a CustomResource created, and the Operator should see it and create a Kubernetes Pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kopf run myoperator.py --verbose
...
[2025-07-18 13:59:58,201] kopf._cogs.clients.w [DEBUG] Starting the watch-stream for customresourcedefinitions.v1.apiextensions.k8s.io cluster-wide.
[2025-07-18 13:59:58,201] kopf._cogs.clients.w [DEBUG] Starting the watch-stream for myapps.v1.demo.rtfm.co.ua cluster-wide.
[2025-07-18 13:59:58,305] kopf.objects [DEBUG] [default/example-app] Creation is in progress: {'apiVersion': 'demo.rtfm.co.ua/v1', 'kind': 'MyApp', 'metadata': {'annotations': {'kubectl.kubernetes.io/last-applied-configuration': '{"apiVersion":"demo.rtfm.co.ua/v1","kind":"MyApp","metadata":{"annotations":{},"name":"example-app","namespace":"default"},"spec":{"banner":"This pod was created by MyApp operator 🚀","image":"nginx:latest","replicas":3}}\n'}, 'creationTimestamp': '2025-07-18T09:55:42Z', 'generation': 2, 'managedFields': [{'apiVersion': 'demo.rtfm.co.ua/v1', 'fieldsType': 'FieldsV1', 'fieldsV1': {'f:metadata': {'f:annotations': {'.': {}, 'f:kubectl.kubernetes.io/last-applied-configuration': {}}}, 'f:spec': {'.': {}, 'f:banner': {}, 'f:image': {}, 'f:replicas': {}}}, 'manager': 'kubectl-client-side-apply', 'operation': 'Update', 'time': '2025-07-18T10:48:27Z'}], 'name': 'example-app', 'namespace': 'default', 'resourceVersion': '2955', 'uid': '8b674a99-05ab-4d4b-8205-725de450890a'}, 'spec': {'banner': 'This pod was created by MyApp operator 🚀', 'image': 'nginx:latest', 'replicas': 3}}
...
[2025-07-18 13:59:58,325] kopf.objects [INFO] [default/example-app] Pod example-app-pod created.
[2025-07-18 13:59:58,326] kopf.objects [INFO] [default/example-app] Handler 'create_myapp' succeeded.
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the Pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk get pod
NAME READY STATUS RESTARTS AGE
example-app-pod 1/1 Running 0 68s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And its logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk logs -f example-app-pod 
This pod was created by MyApp operator 🚀
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So, the Operator launched the Pod using our CustomResource in which he took the &lt;code&gt;spec.banner&lt;/code&gt; field with the string &lt;em&gt;"This pod was created by MyApp operator 🚀&lt;/em&gt;", and executed the &lt;code&gt;command /bin/sh -c " $BANNER"&lt;/code&gt; command in the pod.&lt;/p&gt;

&lt;h3&gt;
  
  
  Resource templates: Kopf and Kubebuilder
&lt;/h3&gt;

&lt;p&gt;Instead of having a separate &lt;code&gt;pod-template.yam&lt;/code&gt;l file, we could describe everything directly in the operator code.&lt;/p&gt;

&lt;p&gt;That is, you can describe something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
    # get optional banner value
    banner = spec.get('banner', '')

    # define Pod spec as a Python dict
    pod_spec = {
        "apiVersion": "v1",
        "kind": "Pod",
        "metadata": {
            "name": f"{name}-pod",
            "labels": {
                "app": name,
            },
        },
        "spec": {
            "containers": [
                {
                    "name": name,
                    "image": image,
                    "env": [
                        {
                            "name": "BANNER",
                            "value": banner
                        }
                    ],
                    "command": ["/bin/sh", "-c"],
                    "args": [f'echo "$BANNER"; exec sleep infinity'],
                    "ports": [
                        {
                            "containerPort": 80
                        }
                    ]
                }
            ]
        }
    }

    # create Kubernetes API client
    api = kubernetes.client.CoreV1Api()
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in the case of Kubebuilder, a function is usually created that uses the CustomResource manifest (&lt;code&gt;cr *myappv1.MyApp&lt;/code&gt;) and forms an object of type &lt;code&gt;*corev1.Pod&lt;/code&gt; using the Go structures &lt;a href="https://github.com/kubernetes/api/blob/master/core/v1/types.go#L3880" rel="noopener noreferrer"&gt;&lt;code&gt;corev1.PodSpec&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://github.com/kubernetes/api/blob/master/core/v1/types.go#L2752" rel="noopener noreferrer"&gt;&lt;code&gt;corev1.Container&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
// newPod is a helper function that builds a Kubernetes Pod object
// based on the custom MyApp resource. It returns a pointer to corev1.Pod,
// which is later passed to controller-runtime's client.Create(...) to create the Pod in the cluster.
func newPod(cr *myappv1.MyApp) *corev1.Pod {
    // `cr` is a pointer to your CustomResource of kind MyApp
    // type MyApp is generated by Kubebuilder and lives in your `api/v1/myapp_types.go`
    // it contains fields like cr.Spec.Image, cr.Spec.Banner, cr.Name, cr.Namespace, etc.
    return &amp;amp;corev1.Pod{
        // corev1.Pod is a Go struct representing the built-in Kubernetes Pod type
        // it's defined in "k8s.io/api/core/v1" package (aliased here as corev1)
        // we return a pointer to it (`*corev1.Pod`) because client-go methods like
        // `client.Create()` expect pointer types

        ObjectMeta: metav1.ObjectMeta{
            // metav1.ObjectMeta comes from "k8s.io/apimachinery/pkg/apis/meta/v1"
            // it defines metadata like name, namespace, labels, annotations, ownerRefs, etc.
            Name: cr.Name + "-pod", // generate Pod name based on the CR's name
            Namespace: cr.Namespace, // place the Pod in the same namespace as the CR
            Labels: map[string]string{ // set a label for identification or selection
                "app": cr.Name, // e.g., `app=example-app`
            },
        },

        Spec: corev1.PodSpec{
            // corev1.PodSpec defines everything about how the Pod runs
            // including containers, volumes, restart policy, etc.

            Containers: []corev1.Container{
                // define a single container inside the Pod

                {
                    Name: cr.Name, // use CR name as container name (must be DNS compliant)
                    Image: cr.Spec.Image, // container image (e.g., "nginx:1.25")

                    Env: []corev1.EnvVar{
                        // corev1.EnvVar is a struct that defines environment variables
                        {
                            Name: "BANNER", // name of the variable
                            Value: cr.Spec.Banner, // value from the CR spec
                        },
                    },

                    Command: []string{"/bin/sh", "-c"},
                    // override container ENTRYPOINT to run a shell command

                    Args: []string{
                        // run a command that prints the banner and sleeps forever
                        // fmt.Sprintf(...) injects the value at runtime into the string
                        fmt.Sprintf(`echo "%s"; exec sleep infinity`, cr.Spec.Banner),
                    },

                    // optional: could also add ports, readiness/liveness probes, etc.
                },
            },
        },
    }
}
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  And what about in real operators?
&lt;/h3&gt;

&lt;p&gt;But we did this for “internal” Kubernetes resources.&lt;/p&gt;

&lt;p&gt;What about external resources?&lt;/p&gt;

&lt;p&gt;Here’s just an example — I haven’t tested it, but the general idea is this: just take an SDK (in the Python example, it’s &lt;code&gt;boto3&lt;/code&gt;), and using the fields from the CustomResource (for example, &lt;code&gt;subnets&lt;/code&gt; or &lt;code&gt;schema&lt;/code&gt;), make the appropriate API requests to AWS through the SDK.&lt;/p&gt;

&lt;p&gt;An example of such a CustomResource:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apiVersion: demo.rtfm.co.ua/v1
kind: MyIngress
metadata:
  name: myapp
spec:
  subnets:
    - subnet-abc
    - subnet-def
  scheme: internet-facing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the code that could create an AWS ALB from it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import kopf
import boto3
import botocore
import logging

# create a global boto3 client for AWS ELBv2 service
# this client will be reused for all requests from the operator
# NOTE: region must match where your subnets and VPC exist
elbv2 = boto3.client("elbv2", region_name="us-east-1")

# define a handler that is triggered when a new MyIngress resource is created
@kopf.on.create('demo.rtfm.co.ua', 'v1', 'myingresses')
def create_ingress(spec, name, namespace, status, patch, logger, **kwargs):
    # extract the list of subnet IDs from the CustomResource 'spec.subnets' field
    # these subnets must belong to the same VPC and be public if scheme=internet-facing
    subnets = spec.get('subnets')

    # extract optional scheme (default to 'internet-facing' if not provided)
    scheme = spec.get('scheme', 'internet-facing')

    # validate input: at least 2 subnets are required to create an ALB
    if not subnets:
        raise kopf.PermanentError("spec.subnets is required.")

    # attempt to create an ALB in AWS using the provided spec
    # using the boto3 ELBv2 client
    try:
        response = elbv2.create_load_balancer(
            Name=f"{name}-alb", # ALB name will be derived from CR name
            Subnets=subnets, # list of subnet IDs provided by user
            Scheme=scheme, # 'internet-facing' or 'internal'
            Type='application', # we are creating an ALB (not NLB)
            IpAddressType='ipv4', # only IPv4 supported here (could be 'dualstack')
            Tags=[ # add tags for ownership tracking
                {'Key': 'ManagedBy', 'Value': 'kopf'},
            ]
        )
    except botocore.exceptions.ClientError as e:
        # if AWS API fails (e.g. invalid subnet, quota exceeded), retry later
        raise kopf.TemporaryError(f"Failed to create ALB: {e}", delay=30)

    # parse ALB metadata from AWS response
    lb = response['LoadBalancers'][0] # ALB list should contain exactly one entry
    dns_name = lb['DNSName'] # external DNS of the ALB (e.g. abc.elb.amazonaws.com)
    arn = lb['LoadBalancerArn'] # unique ARN of the ALB (used for deletion or listeners)

    # log the creation for operator diagnostics
    logger.info(f"Created ALB: {dns_name}")

    # save ALB info into the CustomResource status field
    # this updates .status.alb.dns and .status.alb.arn in the CR object
    patch.status['alb'] = {
        'dns': dns_name,
        'arn': arn,
    }

    # return a dict, will be stored in the finalizer state
    # used later during deletion to clean up the ALB
    return {'alb-arn': arn}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the case of Go and Kubebuilder, we would use the &lt;a href="https://github.com/aws/aws-sdk-go-v2" rel="noopener noreferrer"&gt;&lt;code&gt;aws-sdk-go&lt;/code&gt;&lt;/a&gt; library:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import (
    "context"
    "fmt"

    elbv2 "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2"
    "github.com/aws/aws-sdk-go-v2/aws"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    networkingv1 "k8s.io/api/networking/v1"
)

func newALB(ctx context.Context, client *elbv2.Client, cr *networkingv1.Ingress) (string, error) {
    // build input for the ALB
    input := &amp;amp;elbv2.CreateLoadBalancerInput{
        Name: aws.String(fmt.Sprintf("%s-alb", cr.Name)),
        Subnets: []string{"subnet-abc123", "subnet-def456"}, // replace with real subnets
        Scheme: elbv2.LoadBalancerSchemeEnumInternetFacing,
        Type: elbv2.LoadBalancerTypeEnumApplication,
        IpAddressType: elbv2.IpAddressTypeIpv4,
        Tags: []types.Tag{
            {
                Key: aws.String("ManagedBy"),
                Value: aws.String("MyIngressOperator"),
            },
        },
    }

    // create ALB
    output, err := client.CreateLoadBalancer(ctx, input)
    if err != nil {
        return "", fmt.Errorf("failed to create ALB: %w", err)
    }

    if len(output.LoadBalancers) == 0 {
        return "", fmt.Errorf("ALB was not returned by AWS")
    }

    // return the DNS name of the ALB
    return aws.ToString(output.LoadBalancers[0].DNSName), nil
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the real &lt;a href="https://kubernetes-sigs.github.io/aws-load-balancer-controller/v1.1/" rel="noopener noreferrer"&gt;AWS ALB Ingress Controller&lt;/a&gt;, the creation of an ALB is called in the &lt;a href="https://github.com/kubernetes-sigs/aws-load-balancer-controller/blob/3241ca92eecaf0167a2ad9edad0ba09f9091ba73/pkg/aws/services/elbv2.go#L290" rel="noopener noreferrer"&gt;&lt;code&gt;elbv2.go&lt;/code&gt;&lt;/a&gt; file :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
func (c *elbv2Client) CreateLoadBalancerWithContext(ctx context.Context, input *elasticloadbalancingv2.CreateLoadBalancerInput) (*elasticloadbalancingv2.CreateLoadBalancerOutput, error) {
  client, err := c.getClient(ctx, "CreateLoadBalancer")
  if err != nil {
    return nil, err
  }
  return client.CreateLoadBalancer(ctx, input)
}
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Actually, that’s all there is to it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;/em&gt; &lt;a href="https://rtfm.co.ua/en/kubernetes-what-are-the-kubernetes-operator-and-customresourcedefinition/" rel="noopener noreferrer"&gt;&lt;em&gt;RTFM: Linux, DevOps, and system administration&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>todayilearned</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>AWS: creating an OpenSearch Service cluster and configuring authentication and authorization</title>
      <dc:creator>Arseny Zinchenko</dc:creator>
      <pubDate>Tue, 16 Sep 2025 06:55:43 +0000</pubDate>
      <link>https://dev.to/aws-heroes/aws-creating-an-opensearch-service-cluster-and-configuring-authentication-and-authorization-5aih</link>
      <guid>https://dev.to/aws-heroes/aws-creating-an-opensearch-service-cluster-and-configuring-authentication-and-authorization-5aih</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj65j5emcorj18ix3o5qr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj65j5emcorj18ix3o5qr.png" width="640" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the previous part, &lt;a href="https://rtfm.co.ua/en/aws-introduction-to-the-opensearch-service-as-a-vector-store/" rel="noopener noreferrer"&gt;AWS: Getting Started with OpenSearch Service as a Vector Store&lt;/a&gt;, we looked at AWS OpenSearch Service in general, figured out how data is organized in it, what shards and nodes are, and what types of instances we actually need for data nodes.&lt;/p&gt;

&lt;p&gt;The next step is to create a cluster and look at authentication, which, in my opinion, is even more complicated than AWS EKS. Although, maybe it’s just a matter of habit.&lt;/p&gt;

&lt;p&gt;What we’re going to do today is manually create an AWS OpenSearch Service cluster, look at the main options for creating a cluster, and then dive into the settings for accessing the cluster and OpenSearch Dashboards with AWS IAM and Fine-grained access control of OpenSearch itself and its Security plugin.&lt;/p&gt;

&lt;p&gt;And in the next part, if I have time to write it, we’ll get to Terraform.&lt;/p&gt;

&lt;h3&gt;
  
  
  Contents
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Manually creating a cluster in AWS Console&lt;/li&gt;
&lt;li&gt;Storage&lt;/li&gt;
&lt;li&gt;Nodes&lt;/li&gt;
&lt;li&gt;Network&lt;/li&gt;
&lt;li&gt;Access &amp;amp;&amp;amp; permissions&lt;/li&gt;
&lt;li&gt;Authentication and authorization&lt;/li&gt;
&lt;li&gt;Configuring Domain Access policy&lt;/li&gt;
&lt;li&gt;Resource-based policy&lt;/li&gt;
&lt;li&gt;IP-based policies and access to the OpenSearch Dashboards&lt;/li&gt;
&lt;li&gt;Identity-based policy&lt;/li&gt;
&lt;li&gt;Fine-grained access control&lt;/li&gt;
&lt;li&gt;Configuring the Fine-grained access control&lt;/li&gt;
&lt;li&gt;Creating an OpenSearch Role&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Manually creating a cluster in AWS Console
&lt;/h3&gt;

&lt;p&gt;We will do a minimal PoC to play around, i.e., with t3 instances in one Availability Zone and without Master Nodes.&lt;/p&gt;

&lt;p&gt;In Production, we also plan to have one small cluster with three dev/staging/prod indexes as a vector store for AWS Bedrock Knowledge Base.&lt;/p&gt;

&lt;p&gt;Documentation from AWS — &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/createupdatedomains.html#createdomains" rel="noopener noreferrer"&gt;Creating OpenSearch Service domains&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Go to Amazon OpenSearch Service &amp;gt; Domains, click “Create domain”.&lt;/p&gt;

&lt;p&gt;Set a name, select “Standard create” to have access to all options:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwinj22oxyuf3m8670dxe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwinj22oxyuf3m8670dxe.png" width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In “Templates”, select “Dev/test” — then you can choose a configuration without Master Nodes and deploy in a single Availability Zone.&lt;/p&gt;

&lt;p&gt;In “Deployment option(s)”, select “Domain without standby”  —  then you will have access to &lt;code&gt;t3&lt;/code&gt; instances:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feav5m11apr0ztcdm1o50.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feav5m11apr0ztcdm1o50.png" width="800" height="701"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The case conveniently shows us the entire setup right away.&lt;/p&gt;

&lt;h3&gt;
  
  
  Storage
&lt;/h3&gt;

&lt;p&gt;We discussed the number of shards per cluster in the previous post. Let’s assume that we plan to have a maximum of 20–30 GiB of data, so we will create 1 primary shard and 1 replica. But the shards will be configured later, when we create indexes with Terraform and &lt;a href="https://registry.terraform.io/providers/opensearch-project/opensearch/latest/docs/resources/index_template" rel="noopener noreferrer"&gt;&lt;code&gt;opensearch_index_template&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And for these two shards, we will create two Data Nodes — one for the primary shard and one for the replica.&lt;/p&gt;

&lt;p&gt;“Engine options” are described in &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/features-by-version.html" rel="noopener noreferrer"&gt;Features by engine version in Amazon OpenSearch Service&lt;/a&gt;. Just leave the default value, the latest version.&lt;/p&gt;

&lt;p&gt;For “Instance family” select “General purpose”, and for “Instance type,” select &lt;code&gt;t3.small.search&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For the “EBS storage size per node” we will take 50 GiB — 20–30 gigabytes for data and a little extra for the operating system itself:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxirojee502h2ql37orea.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxirojee502h2ql37orea.png" width="800" height="742"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Nodes
&lt;/h3&gt;

&lt;p&gt;Leave “Number of master nodes” and “Dedicated coordinator nodes” unchanged, i.e. without them:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw9v14vuasm3qn62mkdxn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw9v14vuasm3qn62mkdxn.png" width="800" height="404"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Network
&lt;/h3&gt;

&lt;p&gt;We are not changing anything in “Custom endpoint” yet, but later you can add your own domain from Route53 with a certificate from AWS Certificate Manager to access the cluster, see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/customendpoint.html" rel="noopener noreferrer"&gt;Creating a custom endpoint for Amazon OpenSearch Service&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In the “Network”, we are going with the simplest option for now, “Public access”, but for Production, we will do it inside the VPC:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs7xlhoammsl999omwp9v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs7xlhoammsl999omwp9v.png" width="800" height="458"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;However, you will need to test access to Dashboards, because if the cluster is created in VPC subnets, IP-based policies cannot be applied to it, see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/vpc.html#vpc-security" rel="noopener noreferrer"&gt;About access policies on VPC domains&lt;/a&gt;. We will discuss IP-based policies further here.&lt;/p&gt;

&lt;h3&gt;
  
  
  Access &amp;amp;&amp;amp; permissions
&lt;/h3&gt;

&lt;p&gt;Fine-grained access control (FGAC) — we’ll disable it for now and take a closer look at this mechanism later. Although I’m not sure it will be necessary, because you can easily divide access to different indexes in a single cluster using IAM.&lt;/p&gt;

&lt;p&gt;SAML, JWT, and IAM Identity Center depend on FGAC, so we’ll skip them too, and I don’t plan to use them in the future, as they are not relevant to our case.&lt;/p&gt;

&lt;p&gt;Cognito is also out of the question — we don’t use it (although later, I may look into integrating with Auth0 or Cognito for Dashboards):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F804%2F0%2AhbO7zW7mPNoman55.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F804%2F0%2AhbO7zW7mPNoman55.png" width="800" height="955"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;“Access policy”” can be compared to S3 Access Policy, or to IAM Policy for EKS, which allows IAM users to access the cluster.&lt;/p&gt;

&lt;p&gt;We will discuss this in more detail in the section on authentication. For now, let’s just leave the default - "Do not set domain level access policy” option selected:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F774%2F0%2AmpgWqGKZT3lWsdrf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F774%2F0%2AmpgWqGKZT3lWsdrf.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The “Off-peak window” is the time of lowest load for installing updates and performing Auto-tune operations.&lt;/p&gt;

&lt;p&gt;Our off-peak time will be at night in the US, so Production will be Central Time (CT) 05:00 UTC.&lt;/p&gt;

&lt;p&gt;But since this is a test PoC, we’ll skip that too.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/auto-tune.html" rel="noopener noreferrer"&gt;Auto-Tune&lt;/a&gt; is also well described and unavailable for our &lt;code&gt;t3&lt;/code&gt; instances.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/service-software.html" rel="noopener noreferrer"&gt;Automatic software update&lt;/a&gt; is a useful feature for Production and will be performed at the time specified in the Off-peak window:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F567%2F0%2A9sGqBzgPWWjyMdCc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F567%2F0%2A9sGqBzgPWWjyMdCc.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In еру “Advanced cluster settings” you can disable &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/APIReference/API_AdvancedOptionsStatus.html" rel="noopener noreferrer"&gt;&lt;code&gt;rest.action.multi.allow_explicit_index&lt;/code&gt;&lt;/a&gt;, but I don't know how our queries will be built, and I think I read somewhere that it can break the Dashboard, so let's leave the default &lt;em&gt;enabled&lt;/em&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F679%2F0%2AvQLR88Gad9BnqDj4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F679%2F0%2AvQLR88Gad9BnqDj4.png" width="679" height="395"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And that’s it, as a result we have the following setup:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F319%2F0%2Af2mecNhF7oH29PWT.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F319%2F0%2Af2mecNhF7oH29PWT.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click “Create” and go have some tea, because creating a cluster takes a long time — longer than EKS, and creating OpenSearch took about 20 minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Authentication and authorization
&lt;/h3&gt;

&lt;p&gt;Now, perhaps, the most interesting part — users and access.&lt;/p&gt;

&lt;p&gt;After creating a cluster, by default we have limited access rights to the OpenSearch API itself:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AdiUVzukwjEOm9Alt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AdiUVzukwjEOm9Alt.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Because in the “Security Configuration” we have an explicit Deny:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F841%2F0%2Ak6vHHTj76j_2c3oD.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F841%2F0%2Ak6vHHTj76j_2c3oD.png" width="800" height="674"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Access to AWS OpenSearch Service has three “levels” — network, IAM, and OpenSearch’s own Security Plugin.&lt;/p&gt;

&lt;p&gt;In IAM, we have two entities —  &lt;strong&gt;Domain Access Policy&lt;/strong&gt; , which we see in Security Configuration &amp;gt; Access Policy (attribute &lt;a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/opensearch_domain#access_policies-1" rel="noopener noreferrer"&gt;&lt;code&gt;access_policies&lt;/code&gt;&lt;/a&gt; in Terraform), and &lt;strong&gt;Identity-based policies&lt;/strong&gt; - which are regular AWS IAM Policies.&lt;/p&gt;

&lt;p&gt;If we talk about these levels in more detail, they look something like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Network: Network &amp;gt; VPC access or Public access parameter: we set the access limit at the network level (see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/vpc.html" rel="noopener noreferrer"&gt;Launching your Amazon OpenSearch Service domains within a VPC&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;or, if we take an analogy with EKS, these are Public and Private API endpoints, or with RDS, creating an instance in public or private subnets&lt;/li&gt;
&lt;li&gt;AWS IAM:&lt;/li&gt;
&lt;li&gt;Domain Access Policies:&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/ac.html#ac-types-resource" rel="noopener noreferrer"&gt;Resource-based policies&lt;/a&gt;: policies that are described directly in the cluster settings&lt;/li&gt;
&lt;li&gt;access is set for IAM Role, IAM User, AWS Accounts to a specific OpenSearch domain&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/ac.html#ac-types-ip" rel="noopener noreferrer"&gt;IP-based policies&lt;/a&gt;: essentially the same as Resource-based policies, but with the ability to allow access without authentication for specific IPs (only if the access type is Public, see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/vpc.html#vpc-security" rel="noopener noreferrer"&gt;VPC versus public domains&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/ac.html#ac-types-identity" rel="noopener noreferrer"&gt;Identity-based policies&lt;/a&gt;: if Resource-based policies are part of the cluster’s security policy settings, then Identity-based policies are regular AWS IAM Policies that are added to a specific user or role&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/fgac.html" rel="noopener noreferrer"&gt;Fine-grained access control&lt;/a&gt; (FGAC): OpenSearch’s own Security Plugin  —  the &lt;a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/opensearch_domain#advanced_security_options-1" rel="noopener noreferrer"&gt;&lt;code&gt;advanced_security_options&lt;/code&gt;&lt;/a&gt; attribute in Terraform&lt;/li&gt;
&lt;li&gt;if in Resource-based policies and Identity-based policies we set rules at the cluster (domain) and index levels, then in FGAC we can additionally describe restrictions on specific documents or fields&lt;/li&gt;
&lt;li&gt;and even if Resource-based policies and Identity-based policies allow access to a resource in the cluster, it can be “trimmed” through Fine-grained access control&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is, the authentication and authorization flow will be as follows:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;AWS API receives a request from the user, for example &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/APIReference/Welcome.html" rel="noopener noreferrer"&gt;&lt;code&gt;es:ESHttpGet&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;AWS IAM performs authentication — checks ACCESS:SECRET keys or Session token&lt;/li&gt;
&lt;li&gt;AWS IAM performs authorization:&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;checks the user’s IAM Policy ( &lt;strong&gt;Identity-based policy&lt;/strong&gt; ), if there is explicit permission here — we skip&lt;/li&gt;
&lt;li&gt;checks the Domain Access Policy ( &lt;strong&gt;Resource-based policy&lt;/strong&gt; ) of the cluster, if there is explicit permission here — we skip&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;The request comes to OpenSearch itself&lt;/li&gt;
&lt;li&gt;If Fine-grained access control is not enabled, we allow it&lt;/li&gt;
&lt;li&gt;If Fine-grained access control is configured, we check internal roles, and if the user is allowed, we execute the request&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Let’s make some accesses and see how it all works.&lt;/p&gt;

&lt;h3&gt;
  
  
  Configuring Domain Access policy
&lt;/h3&gt;

&lt;p&gt;The basic option is to add IAM User access to the cluster.&lt;/p&gt;

&lt;h3&gt;
  
  
  Resource-based policy
&lt;/h3&gt;

&lt;p&gt;Edit the “Access policy” and specify your user, API operation types, and domain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::492***148:user/arseny.zinchenko"
      },
      "Action": "es:*",
      "Resource": "arn:aws:es:us-east-1:492***148:domain/test/*"
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F639%2F0%2AlEFLvcRQNeVBOYjR.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F639%2F0%2AlEFLvcRQNeVBOYjR.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Wait a minute, and now we have access to the OpenSearch API (because Cluster health in the AWS Console is obtained from OpenSearch — see &lt;a href="https://docs.opensearch.org/latest/api-reference/cluster-api/cluster-health/" rel="noopener noreferrer"&gt;Cluster Health API&lt;/a&gt;):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AOr6UlHivW2wHAB3F.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AOr6UlHivW2wHAB3F.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And now we can use &lt;code&gt;curl&lt;/code&gt; and &lt;a href="https://curl.se/docs/manpage.html" rel="noopener noreferrer"&gt;&lt;code&gt;--aws-sigv4&lt;/code&gt;&lt;/a&gt; to access the cluster (see &lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html" rel="noopener noreferrer"&gt;Authenticating Requests (AWS Signature Version 4)&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl --aws-sigv4 "aws:amz:us-east-1:es" \
&amp;gt; --user "AKI ***B7A:pAu*** 2gW" \
&amp;gt; https://search-test-***.us-east-1.es.amazonaws.com/_cluster/health?pretty
{
  "cluster_name" : "492***148:test",
  "status" : "green",
  "timed_out" : false,
  "number_of_nodes" : 2,
  "number_of_data_nodes" : 2,
  "discovered_master" : true,
  "discovered_cluster_manager" : true,
  "active_primary_shards" : 5,
  "active_shards" : 10,
  "relocating_shards" : 0,
  "initializing_shards" : 0,
  "unassigned_shards" : 0,
  "delayed_unassigned_shards" : 0,
  "number_of_pending_tasks" : 0,
  "number_of_in_flight_fetch" : 0,
  "task_max_waiting_in_queue_millis" : 0,
  "active_shards_percent_as_number" : 100.0
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  IP-based policies and access to the OpenSearch Dashboards
&lt;/h3&gt;

&lt;p&gt;Similarly, through Domain Access Policy, we can open access to Dashboards — the simplest option, but it only works with Public domains. If the cluster is in VPC, additional authentication will be required, see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/dashboards.html#dashboards-access" rel="noopener noreferrer"&gt;Controlling access to Dashboards&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Edit the policy, add condition &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_examples_aws_deny-ip.html" rel="noopener noreferrer"&gt;&lt;code&gt;IpAddress.aws:SourceIp&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::492***148:user/arseny.zinchenko"
      },
      "Action": "es:*",
      "Resource": "arn:aws:es:us-east-1:492***148:domain/test/*"
    },
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "*"
      },
      "Action": "es:ESHttp*",
      "Resource": "arn:aws:es:us-east-1:492***148:domain/test/*",
      "Condition": {
        "IpAddress": {
          "aws:SourceIp": "178. ***.***.184"
        }
      }
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And now we have access to the Dashboards:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2Ai9atXE7MAu3yPeeA.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2Ai9atXE7MAu3yPeeA.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Identity-based policy
&lt;/h3&gt;

&lt;p&gt;Now, the second option is to create a separate IAM User and connect a separate IAM Policy to it.&lt;/p&gt;

&lt;p&gt;Add a user in AWS IAM:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AJCWVpo0xSuWcUtuY.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AJCWVpo0xSuWcUtuY.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We can just take a ready-made &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/ac-managed.html" rel="noopener noreferrer"&gt;AWS managed policies for Amazon OpenSearch Service&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2A-HyXuvJI4Pc8lXy5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2A-HyXuvJI4Pc8lXy5.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Next, we simply create access keys for the Command Line Interface (CLI) and, without changing anything in the cluster’s Access policy, check access:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl --aws-sigv4 "aws:amz:us-east-1:es" --user "AKI ***YUK:fXV*** 34I" https://search-test-***.us-east-1.es.amazonaws.com/_cluster/health?pretty
{
  "cluster_name" : "492***148:test",
  "status" : "green",
  "timed_out" : false,
  "number_of_nodes" : 2,
  "number_of_data_nodes" : 2,
  "discovered_master" : true,
  "discovered_cluster_manager" : true,
  "active_primary_shards" : 5,
  "active_shards" : 10,
  "relocating_shards" : 0,
  "initializing_shards" : 0,
  "unassigned_shards" : 0,
  "delayed_unassigned_shards" : 0,
  "number_of_pending_tasks" : 0,
  "number_of_in_flight_fetch" : 0,
  "task_max_waiting_in_queue_millis" : 0,
  "active_shards_percent_as_number" : 100.0
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So now we have a Domain Access Policy that grants access specifically to my user, and there is a separate IAM Policy — an identity-based policy — that grants access to the test user.&lt;/p&gt;

&lt;p&gt;But there is one important point here: in the IAM Policy, we specify either the entire domain or only its subresources.&lt;/p&gt;

&lt;p&gt;That is, if instead of the &lt;code&gt;AmazonOpenSearchServiceFullAccess&lt;/code&gt; policy, we create our own policy in which we specify &lt;code&gt;"Resource":***:domain/test/*"&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "es:*"
            ],
            "Resource": "arn:aws:es:us-east-1:492***148:domain/test/*"
        }
    ]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So we can execute &lt;code&gt;es:ESHttpGet&lt;/code&gt; (&lt;code&gt;GET _cluster/health&lt;/code&gt;) - but we cannot execute cluster-level operations, such as &lt;code&gt;es:AddTags&lt;/code&gt;, even though we have permission for all calls in the &lt;code&gt;Actions&lt;/code&gt; of the IAM policy - &lt;code&gt;es:*&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ aws --profile test-os opensearch add-tags --arn arn:aws:es:us-east-1:492***148:domain/test --tag-list Key=environment,Value=test

An error occurred (AccessDeniedException) when calling the AddTags operation: User: arn:aws:iam::492 ***148:user/test-opesearch-identity-based-policy is not authorized to perform: es:AddTags on resource: arn:aws:es:us-east-1:492*** 148:domain/test because no identity-based policy allows the es:AddTags action
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If we want to allow all operations with the cluster, we need to set &lt;code&gt;"Resource"&lt;/code&gt; to &lt;code&gt;"arn:aws:es:us-east-1:492***148:domain/test"&lt;/code&gt;, and then we can add tags.&lt;/p&gt;

&lt;p&gt;See all API actions in &lt;a href="https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazonopensearchservice.html" rel="noopener noreferrer"&gt;Actions, resources, and condition keys for Amazon OpenSearch Service&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fine-grained access control
&lt;/h3&gt;

&lt;p&gt;Documentation — &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/fgac.html" rel="noopener noreferrer"&gt;Fine-grained access control in Amazon OpenSearch Service&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The basic idea is very similar to Kubernetes RBAC.&lt;/p&gt;

&lt;p&gt;In OpenSearch, there are three main concepts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;users&lt;/strong&gt;  — like Kubernetes Users and ServiceAccounts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;roles&lt;/strong&gt;  — like Kubernetes RBAC Roles&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mappings&lt;/strong&gt;  — like Kubernetes Role Bindings&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Users can be from both AWS IAM and the internal OpenSearch database.&lt;/p&gt;

&lt;p&gt;As in Kubernetes, OpenSearch has a set of default roles — see &lt;a href="https://docs.opensearch.org/1.0/security-plugin/access-control/users-roles/#predefined-roles" rel="noopener noreferrer"&gt;Predefined roles&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;At the same time, roles, as in Kubernetes, can be cluster-wide or index-specific — analogous to ClusterRoleBinding and simply namespaced RoleBinding in Kubernetes, plus in OpenSearch FGAC you can additionally have document level or field level permissions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Configuring the Fine-grained access control
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Important note&lt;/strong&gt; : once FGAC is enabled, you will not be able to revert to the old scheme. However, all accesses from IAM will remain, even if you switch to the internal database.&lt;/p&gt;

&lt;p&gt;Edit “Security configuration” and enable “Fine-grained access control”:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2ACrUCeN88_UIAzLAc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2ACrUCeN88_UIAzLAc.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;First, we need to set up a Master user, which can be specified from IAM or created locally in OpenSearch.&lt;/p&gt;

&lt;p&gt;If we create a user via the “Create master user” option, we specify a regular login:password, and in this case, OpenSearch will connect to the internal user database (&lt;a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/opensearch_domain#internal_user_database_enabled-1" rel="noopener noreferrer"&gt;&lt;code&gt;internal_user_database_enabled&lt;/code&gt;&lt;/a&gt; in Terraform).&lt;/p&gt;

&lt;p&gt;If we use the internal OpenSearch database, we can have regular users and perform HTTP basic authentication. See the AWS documentation — &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/fgac-http-auth.html" rel="noopener noreferrer"&gt;Tutorial: Configure a domain with the internal user database and HTTP basic authentication&lt;/a&gt; and &lt;a href="https://docs.opensearch.org/latest/security/access-control/users-roles/" rel="noopener noreferrer"&gt;Defining users and roles&lt;/a&gt; in the OpenSearch documentation itself, as these are its internal mechanisms.&lt;/p&gt;

&lt;p&gt;This makes sense if you don’t want to use Cognito or SAML, and if each cluster will have its own user settings.&lt;/p&gt;

&lt;p&gt;If you set an IAM user, the scheme will be similar to AIM authentication for RDS and IAM database authentication — access to the cluster is controlled by AWS IAM, but internal access to schemas and databases is controlled by PostgreSQL or MariaDB roles, see &lt;a href="https://rtfm.co.ua/aws-rds-z-iam-database-authentication-eks-pod-identities-ta-terraform/" rel="noopener noreferrer"&gt;AWS: RDS with IAM database authentication, EKS Pod Identities, and Terraform&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In this case, AWS IAM will only perform user authentication, while authorization (access rights verification) will be handled by the Security plugin and OpenSearch roles.&lt;/p&gt;

&lt;p&gt;Let’s try a local database, and I think we’ll use this scheme in Production as well:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F820%2F0%2AvA5RDXZGFalLk_NY.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F820%2F0%2AvA5RDXZGFalLk_NY.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We can leave “Access Policy” as it is:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F820%2F0%2AtQPRfTfRaSEFKC3s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F820%2F0%2AtQPRfTfRaSEFKC3s.png" width="800" height="813"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Switching to the internal database will take some time because it will trigger a blue/green deployment of the new cluster, see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/managedomains-configuration-changes.html" rel="noopener noreferrer"&gt;Making configuration changes in Amazon OpenSearch Service&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And it took a long time, &lt;strong&gt;more than an hour&lt;/strong&gt; , even though there is no data of ours in the cluster.&lt;/p&gt;

&lt;p&gt;Once the changes are applied, Dashboards will now ask for a login and password. Use our Master user:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F380%2F0%2AZ86bTUcjCpD4QOfG.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F380%2F0%2AZ86bTUcjCpD4QOfG.png" width="380" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The master user receives two connected roles: &lt;code&gt;all_access&lt;/code&gt; and &lt;code&gt;security_manager&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It is &lt;code&gt;security_manager&lt;/code&gt; that provides access to the Security and Users sections in the dashboard:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F822%2F0%2AMWquXL1t7t1nvuon.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F822%2F0%2AMWquXL1t7t1nvuon.png" width="800" height="508"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2ADKi2Y9yfb_hL_8Sc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2ADKi2Y9yfb_hL_8Sc.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At the same time, we still have access to our AIM users, and we can continue to use curl: IAM users will be mapped to the &lt;code&gt;default_role&lt;/code&gt;, which allows GET/PUT on all indexes - see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/fgac.html" rel="noopener noreferrer"&gt;About the default_role&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AQTycy0HCfNYZgwnB.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AQTycy0HCfNYZgwnB.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let’s check our test user’s access now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl --aws-sigv4 "aws:amz:us-east-1:es" --user "AKI ***YUK:fXV*** 34I" https://search-test-***.us-east-1.es.amazonaws.com/_cluster/health?pretty
{
  "cluster_name" : "492***148:test",
  "status" : "green",
  "timed_out" : false,
  "number_of_nodes" : 2,
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now let’s cut off access to all IAM users.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating an OpenSearch Role
&lt;/h3&gt;

&lt;p&gt;To see how it works, let’s add a test index and map our test user with access to this index.&lt;/p&gt;

&lt;p&gt;Add the index:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2APQUDJumbZSXrBFmU.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2APQUDJumbZSXrBFmU.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F866%2F0%2A60U1pDJYwQYxHBvV.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F866%2F0%2A60U1pDJYwQYxHBvV.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Go to Security &amp;gt; Roles, add a role:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AMLrRYcAFOBA8vmZl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AMLrRYcAFOBA8vmZl.png" width="800" height="149"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Set Index permissions — full access to the index (&lt;code&gt;crud&lt;/code&gt;):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AhToY-wA5E8fBqeLf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AhToY-wA5E8fBqeLf.png" width="800" height="773"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Next, in this role, we move on to Mapped users &amp;gt; Map users:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2APECCieUOba6ggmoq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2APECCieUOba6ggmoq.png" width="800" height="314"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And add the ARN of our test user:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F924%2F0%2AwnJsiGZqHWh-TdxF.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F924%2F0%2AwnJsiGZqHWh-TdxF.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Delete the default role:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2A6dhZJhZTvhPEGaE9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2A6dhZJhZTvhPEGaE9.png" width="800" height="189"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now our user does not have access to &lt;code&gt;GET _cluster/health&lt;/code&gt; - here we get an error &lt;strong&gt;403, no permissions&lt;/strong&gt; :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl --aws-sigv4 "aws:amz:us-east-1:es" --user "AKI ***YUK:fXV*** 34I" https://search-test-***.us-east-1.es.amazonaws.com/_cluster/health?pretty
{
  "error" : {
    ...
    "type" : "security_exception",
    "reason" : "no permissions for [cluster:monitor/health] and User [name=arn:aws:iam::492***148:user/test-opesearch-identity-based-policy, backend_roles=[], requestedTenant=null]"
  },
  "status" : 403
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But has access to the test index:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl --aws-sigv4 "aws:amz:us-east-1:es" --user "AKI ***YUK:fXV*** 34I" https://search-test-***.us-east-1.es.amazonaws.com/test-allowed-index/_search?pretty -d '{
    "query": {
      "match_all": {}
    }
  }' -H 'Content-Type: application/json'
{
  "took" : 78,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 0,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : []
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Done.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;/em&gt; &lt;a href="https://rtfm.co.ua/en/aws-creating-an-opensearch-service-cluster-and-configuring-authentication-and-authorization/" rel="noopener noreferrer"&gt;&lt;em&gt;RTFM: Linux, DevOps, and system administration&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>devops</category>
      <category>aws</category>
      <category>security</category>
    </item>
  </channel>
</rss>
