<?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: M. Abdullah Bin Aftab</title>
    <description>The latest articles on DEV Community by M. Abdullah Bin Aftab (@heighter).</description>
    <link>https://dev.to/heighter</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%2F1276763%2F8516c785-2ecf-4b28-ac6a-a96156980c01.png</url>
      <title>DEV Community: M. Abdullah Bin Aftab</title>
      <link>https://dev.to/heighter</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/heighter"/>
    <language>en</language>
    <item>
      <title>Building a Serverless Clickstream Analytics Pipeline: AWS to Microsoft Fabric, End-to-End</title>
      <dc:creator>M. Abdullah Bin Aftab</dc:creator>
      <pubDate>Tue, 28 Jul 2026 11:18:59 +0000</pubDate>
      <link>https://dev.to/heighter/building-a-serverless-clickstream-analytics-pipeline-aws-to-microsoft-fabric-end-to-end-5fb2</link>
      <guid>https://dev.to/heighter/building-a-serverless-clickstream-analytics-pipeline-aws-to-microsoft-fabric-end-to-end-5fb2</guid>
      <description>&lt;p&gt;A field report from building a full clickstream analytics pipeline for a nonprofit donation platform, from a browser click all the way to a live &lt;strong&gt;Power BI&lt;/strong&gt; dashboard, including every real bug we hit along the way.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem We Were Actually Solving
&lt;/h2&gt;

&lt;p&gt;We run a donation platform for a nonprofit (I'll call it &lt;code&gt;xyz_foundation&lt;/code&gt; throughout this post) built on Gatsby (React) with a serverless AWS backend (Amplify Gen2 / CDK). The donation flow is a multi-step form: pick a donation type, an amount, a recurrence, who's giving, a region, a payment method, then complete payment via Stripe or PayPal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The problem:&lt;/strong&gt; we had zero visibility into how people actually used that form. We didn't know which donation amounts were most popular, where people dropped off in the funnel, whether people preferred one-time or recurring gifts, or which payment method won out. Our own UX team was designing changes to this flow based on guesswork, not data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We needed:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Real click and page-view tracking across the whole site, especially the donation funnel.&lt;/li&gt;
&lt;li&gt;A way to query and aggregate that data cheaply, without building a whole data engineering team's worth of infrastructure.&lt;/li&gt;
&lt;li&gt;A dashboard a non-technical UX designer could actually use to answer real questions ("what's the most popular donation amount?", "where do people abandon the form?").&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This post walks through the entire stack we ended up with, why we made each decision, and every bug we hit building it, since those are the parts nobody writes about in the clean architecture diagrams.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture, End to End
&lt;/h2&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%2Fflrwc18f7n24oz55vnxp.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%2Fflrwc18f7n24oz55vnxp.png" alt=" " width="799" height="362"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every piece of this is serverless: no servers to patch, no clusters to manage, and (as I'll show at the end) genuinely cheap at low-to-moderate scale.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 1: Getting Data Off the Browser
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The first design decision we got wrong
&lt;/h3&gt;

&lt;p&gt;Our original plan was the "obvious" one: use the AWS Amplify Analytics SDK to write directly from the browser to Kinesis Firehose, using temporary AWS credentials issued via a Cognito Identity Pool.&lt;/p&gt;

&lt;p&gt;This is a complete dead end for anonymous visitors, and it's worth explaining why, because it's a trap a lot of teams fall into. Cognito Identity Pools have a setting called "unauthenticated identities," and if it's disabled (which is common, sometimes for security/compliance reasons, sometimes because someone else disabled it), anonymous visitors cannot get AWS credentials at all. No credentials means no way to directly write to Firehose. This isn't a code bug, it's a live account-level setting, and in our case, changing it wasn't an option (it was a shared setting affecting other parts of the platform).&lt;/p&gt;

&lt;p&gt;We only found this out after building the whole direct-write path and testing it, which cost real time. &lt;strong&gt;Lesson:&lt;/strong&gt; verify your identity provider's anonymous-access settings before designing anything that depends on unauthenticated AWS credentials.&lt;/p&gt;

&lt;h3&gt;
  
  
  What we built instead: an API Gateway proxy
&lt;/h3&gt;

&lt;p&gt;Since the browser can't hold AWS credentials, we put a plain HTTPS endpoint in front of Firehose:&lt;/p&gt;

&lt;p&gt;The Lambda is the only thing in the whole system with permission to write to Firehose. The browser never touches AWS directly; it just POSTs a small JSON envelope to a public URL. This is a strictly better architecture than the credentials-in-the-browser approach anyway, even setting aside the Cognito issue: it means we can validate, rate-limit, and reject malformed payloads server-side before anything reaches the data lake, and revoking access is as simple as changing one Lambda's IAM policy, not credentials that were previously pushed to every browser that ever visited the site.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified shape of the ingest handler&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_BODY_BYTES&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="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid request&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;envelope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;\n`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf-8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;firehose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PutRecordCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;DeliveryStreamName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;STREAM_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;Record&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&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="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;202&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Accepted&lt;/span&gt;&lt;span class="dl"&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;Two details worth calling out:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trailing &lt;code&gt;\n&lt;/code&gt; is not optional.&lt;/strong&gt; Firehose concatenates record bytes in S3 with no separator by default. If you don't manually add a newline between JSON records, multiple events landing in the same S3 file produce a single invalid JSON blob, silently breaking every downstream query engine that expects newline-delimited JSON (NDJSON). This is the kind of thing that works perfectly in testing (one record per file) and breaks the moment real traffic causes multiple records to batch together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Always return success, even on internal failure.&lt;/strong&gt; A broken analytics pipe should never surface as a visible error to a real visitor trying to donate. Our handler always returns &lt;code&gt;202 Accepted&lt;/code&gt; to the browser, logging failures internally instead of propagating them. Analytics is fire-and-forget from the product's point of view,  it should never be allowed to block or visibly break the actual product.&lt;/p&gt;

&lt;h3&gt;
  
  
  The frontend tracking utility
&lt;/h3&gt;

&lt;p&gt;A tiny, dependency-free utility handles sending events:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;trackEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eventName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;API_URL&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;envelope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;eventName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getOrCreateSessionId&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;API_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;keepalive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;Two things worth explaining:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;keepalive: true&lt;/code&gt; ensures the request completes even if the user navigates away immediately (e.g., clicking a link that triggers a page transition). Without this, the browser can cancel in-flight requests during navigation, silently dropping events right when they matter most (like the final "Donate" click).&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;session ID&lt;/strong&gt; is generated once per browser tab (stored in &lt;code&gt;sessionStorage&lt;/code&gt;, not &lt;code&gt;localStorage&lt;/code&gt;) so events from the same visit can be grouped into one journey, without ever identifying who the person actually is. This one field turned out to be one of the most valuable things in the whole pipeline, it's what makes every funnel and drop-off analysis possible later.&lt;/p&gt;

&lt;h3&gt;
  
  
  The bug that cost us the most debugging time: a missed page view
&lt;/h3&gt;

&lt;p&gt;We instrumented page views using our frontend framework's route-change lifecycle hook. It turns out that hook doesn't fire on the very first page load, only on subsequent client-side navigations. Any visitor who lands directly on a deep page (a shared link, a bookmark, a direct URL to the donation form) and interacts with it without navigating anywhere else first was completely invisible to our page-view tracking.&lt;/p&gt;

&lt;p&gt;We only found this by cross-referencing real session data: some sessions had form-selection events but no page views at all. The fix was a second, separate hook that fires once on initial app load, complementing the route-change hook rather than replacing it. &lt;strong&gt;Lesson:&lt;/strong&gt; framework "route change" lifecycle hooks are not the same thing as "page load"; check both explicitly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 2: Storage and Cataloging
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Partitioning: the single most important cost lever
&lt;/h3&gt;

&lt;p&gt;Firehose writes into S3 automatically organized by &lt;code&gt;year/month/day/hour&lt;/code&gt; (in UTC, always, and it trips people up if your team isn't in UTC). This partition structure is the foundation of everything downstream, because of one core Athena/Presto concept:&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;partition key&lt;/strong&gt; is a column whose value lives in the file's storage path, not inside the file's content. A normal column filter (&lt;code&gt;WHERE eventname = 'x'&lt;/code&gt;) requires Athena to open and read every file, then discard non-matching rows, the expensive part happens before the filter helps at all. A partition filter (&lt;code&gt;WHERE year = 2026 AND month = 7 AND day = 21&lt;/code&gt;) lets Athena skip entire folders it can prove don't match, without opening a single byte inside them. Since Athena bills per data scanned, this is a direct, sometimes dramatic cost and speed lever, not a micro-optimization.&lt;/p&gt;

&lt;p&gt;We used &lt;strong&gt;partition projection&lt;/strong&gt; (a Glue Data Catalog feature) instead of manually registering partitions. Traditionally, adding new partitioned data means running &lt;code&gt;MSCK REPAIR TABLE&lt;/code&gt; or manually registering each new partition before Athena will look at it. Partition projection instead tells Athena to derive partition values mathematically from a defined range (e.g., "hour is always between 0 and 23"), so new data becomes queryable the instant it lands, with zero maintenance:&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;"projection.enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"true"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"projection.year.type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"integer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"projection.year.range"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2024,2035"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"projection.hour.type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"integer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"projection.hour.range"&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,23"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"storage.location.template"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3://bucket/${year}/${month}/${day}/${hour}/"&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;One real gotcha: if you query this kind of table without a partition filter, and your projection covers a huge window (multiple years, all months/days/hours), Athena may need to check tens of thousands of hypothetical partition paths against S3 before it can return anything, turning a query that should take one second into one that takes minutes. We hit this directly: a &lt;code&gt;SELECT * LIMIT 10&lt;/code&gt; with no &lt;code&gt;WHERE&lt;/code&gt; clause took nearly three minutes, while the identical query with a date filter took about a second. Partition projection is a phenomenal tool, but it is not a substitute for filtering by partition columns in every query.&lt;/p&gt;

&lt;h3&gt;
  
  
  A real, subtle JSON parsing bug: case sensitivity
&lt;/h3&gt;

&lt;p&gt;We used the &lt;code&gt;org.openx.data.jsonserde.JsonSerDe&lt;/code&gt; library to let Athena parse our raw JSON. This SerDe has a setting, &lt;code&gt;case.insensitive&lt;/code&gt;, that defaults to &lt;code&gt;true&lt;/code&gt;, meaning it silently lowercases every JSON key while parsing, including keys nested inside sub-objects.&lt;/p&gt;

&lt;p&gt;This caused a genuinely confusing bug: a field named &lt;code&gt;optionLabel&lt;/code&gt; (camelCase) in our raw JSON came back as &lt;code&gt;null&lt;/code&gt; every time we tried to extract it in a query, even though we could see the correctly-cased data sitting in the raw S3 files with our own eyes. The SerDe was silently transforming &lt;code&gt;optionLabel&lt;/code&gt; into &lt;code&gt;optionlabel&lt;/code&gt; during parsing, and our extraction query was looking for the camelCase version, which no longer existed post-parse.&lt;/p&gt;

&lt;p&gt;The fix required setting &lt;code&gt;case. insensitive: false&lt;/code&gt;, but that alone broke something else: our top-level fields (&lt;code&gt;eventName&lt;/code&gt;, &lt;code&gt;sessionId&lt;/code&gt; in the raw JSON) didn't match our Glue table's column names (&lt;code&gt;eventname&lt;/code&gt;, &lt;code&gt;sessionid&lt;/code&gt;, lowercase by Hive convention), and turning off case-insensitivity meant those top-level fields stopped resolving too. The complete fix needed explicit &lt;code&gt;mapping.*&lt;/code&gt; SerDe properties for every field with inconsistent casing:&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;"mapping.eventname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eventName"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mapping.sessionid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sessionId"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"case.insensitive"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;&lt;strong&gt;Lesson:&lt;/strong&gt; if you're using a JSON SerDe with mixed-case source keys, don't rely on the default behavior; it silently mangles nested fields in ways that are hard to spot, since the query still "succeeds," it just returns &lt;code&gt;null&lt;/code&gt; instead of erroring.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 3: Querying and the Materialization Pattern
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Views vs. tables, and why we needed both
&lt;/h3&gt;

&lt;p&gt;An Athena &lt;strong&gt;view&lt;/strong&gt; is a saved question, re-computed fresh every single time it's queried; no data is stored under the view's name. This is perfect for ad-hoc, cheap, always-current exploration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;clean_events&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;eventname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;sessionid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from_iso8601_timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;event_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;json_extract_scalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'$.label'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;button_label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;json_extract_scalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'$.field'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;selection_field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;json_extract_scalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'$.optionLabel'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;selection_option&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nb"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But views have no fixed output location; nothing outside Athena can point at a stable file path and expect to find this data. For that, we needed a genuinely materialized (physically written) table, refreshed on a schedule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;clean_events_export&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;FROM&lt;/span&gt; &lt;span class="n"&gt;clean_events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nb"&gt;year&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;day&lt;/span&gt; &lt;span class="o"&gt;=&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="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A small Lambda, triggered hourly by an EventBridge Scheduler, computes "the previous complete hour" with one query. Because each run only ever targets one specific, never-repeated hour, there's no risk of duplicate data, the schedule itself acts as the deduplication mechanism, with no separate watermark or state tracking needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  A subtle type mismatch that only shows up in "Direct Lake" style engines
&lt;/h3&gt;

&lt;p&gt;We stored &lt;code&gt;event_time&lt;/code&gt; as a plain string in our export table for a long time, since it started life as raw ISO-8601 text. This worked fine for ordinary querying, but broke the moment we tried to use it as a real datetime column in a "Direct Lake"-style BI engine (Microsoft Fabric's zero-copy semantic layer, discussed more below). The error was precise and worth understanding:&lt;/p&gt;

&lt;p&gt;Direct Lake-style engines read raw Parquet bytes directly, with no transformation layer in between, whereas a traditional "Import" pipeline can freely reinterpret a column's type during its own copy step. A zero-copy engine cannot, the semantic layer's declared type must physically match what's stored in the file, or it fails outright rather than silently coercing. The fix had to happen upstream, at the query that produces the Parquet file, casting the string into a genuine timestamp before it's ever written:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from_iso8601_timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;event_time&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; if you're feeding a zero-copy/Direct-Lake-style BI layer, get your column types right upstream; you can't paper over a physical type mismatch downstream in the BI tool.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 4: Getting Data Into a BI Tool
&lt;/h2&gt;

&lt;p&gt;This is the part with the least documentation online, and the most trial and error.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three ways to connect a cloud data warehouse to a BI tool, and their real tradeoffs
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A direct live connector&lt;/strong&gt; (e.g., Athena's native BI connector): the cleanest conceptually, but in our experience with Microsoft Fabric specifically, this connector required an on-premises data gateway even when used from Fabric's web-based authoring surface, meaning a Windows machine has to live somewhere, permanently. If your team is Mac-only with no Windows infrastructure, this is a hard blocker, not a minor inconvenience.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A "shortcut"&lt;/strong&gt; (Fabric's term; other platforms have similar zero-copy external-reference features): a live pointer into cloud object storage, with no gateway required, since it uses direct cloud-to-cloud authentication instead of an ODBC-driver-on-a-gateway-machine model. This turned out to be great for ad-hoc exploration.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A scheduled copy job&lt;/strong&gt;: periodically pulls data from object storage into the BI tool's own storage. It's also gateway-free (cloud-to-cloud), and unlike a live connector, lets the BI layer read from its own optimized native table format afterward, rather than querying external files on every interaction.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We ended up using &lt;strong&gt;option 3&lt;/strong&gt; for the production dashboard, since it let us land data as the BI tool's native table format (better performance, native type support) while still being entirely gateway-free.&lt;/p&gt;

&lt;h3&gt;
  
  
  Real bugs we hit setting up the Copy Job
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Bug 1: wrong region in the signing header.&lt;/strong&gt; &lt;em&gt;The authorization header is malformed; the region 'us-east-1' is wrong; expecting 'ap-southeast-2'.&lt;/em&gt; This happened because the S3 endpoint URL we configured was the generic global endpoint, not a region-and-bucket-specific one. Fix: use the bucket-specific endpoint URL (&lt;code&gt;https://bucket-name.s3.region.amazonaws.com&lt;/code&gt;), which unambiguously encodes the signing region.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 2: SSL certificate trust failure&lt;/strong&gt;, immediately after fixing bug 1: &lt;em&gt;Could not establish trust relationship for the SSL/TLS secure channel. The remote certificate is invalid.&lt;/em&gt; This one turned out to be caused by the fix for bug 1; the connector apparently also appended the bucket name itself, so combining it with an already bucket-specific URL produced a malformed, doubled hostname that didn't match S3's actual certificate. Fix: revert to the generic endpoint at the connection level, and let the bucket name be specified only once, in the copy activity's own path configuration, not baked into the connection URL too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 3: wrong file format assumed.&lt;/strong&gt; &lt;em&gt;ErrorCode=DelimitedTextBadDataDetected ... CsvHelper.BadDataException.&lt;/em&gt; Athena writes Parquet output files with no file extension by default, just a random ID as the filename. Some connectors infer file format from the extension; with no extension to go on, ours silently defaulted to treating our binary Parquet data as CSV text, producing exactly the kind of garbled parsing error you'd expect from feeding binary data through a text parser. Fix: explicitly set the file format to Parquet in the copy job's configuration; never rely on extension-based auto-detection against Athena-written output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 4: a genuinely wasteful funnel bug caught by a UX sanity check, not a technical error.&lt;/strong&gt; After getting real data flowing, our conversion funnel showed more sessions reaching a later stage than an earlier one, logically impossible for a proper funnel, and a giveaway that something was wrong even before we knew the cause. Tracing it down to actual session-level data revealed the true cause: some sessions had donation-form interactions with no corresponding page-view event at all. This was the same "missed initial page load" bug described in Part 1, showing up again downstream, this time as a funnel-integrity problem rather than a raw-data gap. &lt;strong&gt;Lesson:&lt;/strong&gt; a funnel that doesn't monotonically shrink is a data-quality signal, not just an aesthetic issue; trace it back to root cause rather than adjusting the chart to hide it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The permanent-bucket-name fix
&lt;/h3&gt;

&lt;p&gt;One more practical wrinkle: if your infrastructure-as-code tool auto-generates a globally-unique bucket name (a common pattern, since S3 names must be unique across all AWS accounts everywhere, not just your own), tearing down and redeploying your stack produces a new bucket name every time, breaking any external tool (like a BI connector) that saved a reference to the old one.&lt;/p&gt;

&lt;p&gt;The fix: pin the bucket name to something both fixed and guaranteed-unique, by baking your account ID into it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;bucketName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`xyz-clickstream-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Account IDs are permanent and globally unique, so this name survives infinite teardown/redeploy cycles without colliding with another AWS account, and without ever needing the downstream BI connection reconfigured again.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 5: The Dashboard Layer
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Semantic layer, DAX measures, and a real funnel chart
&lt;/h3&gt;

&lt;p&gt;On top of the synced table, we built a small set of reusable calculated measures (DAX, in Power BI) rather than manually configuring aggregations inside every chart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unique Visitors = DISTINCTCOUNT(clean_events[sessionid])

Donate Conversion Rate = DIVIDE([Donate Clicks], [Unique Visitors])

Sessions Reached Form = CALCULATE(
    DISTINCTCOUNT(clean_events[sessionid]),
    clean_events[eventname] = "page_view",
    CONTAINSSTRING(clean_events[page_path], "/donate/")
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four such "stage" measures (reached the form → made a selection → clicked the main CTA → picked a payment method) plugged directly into a dedicated funnel chart visual gives a UX team exactly the shrinking-funnel-with-drop-off-percentages view they actually want, computed live from real data instead of guesswork.&lt;/p&gt;

&lt;h3&gt;
  
  
  A consolidation trick worth knowing
&lt;/h3&gt;

&lt;p&gt;Rather than building one bar chart per form field (8+ separate visuals for donation type, region, payment method, etc.), we found combining them into a single stacked bar chart X-axis: field name, Legend: option chosen, Value: count - gave a UX designer the entire form's preference landscape in one glance, with each bar clickable to drill into that specific field. Far less canvas space, same information density.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keeping it current automatically
&lt;/h3&gt;

&lt;p&gt;Modern BI platforms increasingly support zero-copy live sync (Fabric's "Direct Lake" automatically), continuously watching the underlying storage layer for changes and updating without any explicit refresh schedule at all. We layered this with a traditional scheduled refresh as a backup safety net (four times a day, offset 15–25 minutes after the upstream copy job's own schedule, giving it time to finish writing first). Belt and suspenders: the automatic detection does the real work, while the scheduled refresh guarantees a worst-case bound on staleness even if the automatic path ever misbehaves.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 6: Timezone Discipline Across the Whole Chain
&lt;/h2&gt;

&lt;p&gt;Worth its own section because it's the kind of bug that doesn't throw an error; it just quietly produces wrong-looking numbers that someone eventually notices and can't explain.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;S3/Firehose partitioning:&lt;/strong&gt; always UTC, not configurable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The export Lambda:&lt;/strong&gt; explicitly written using UTC-based date functions, matching Firehose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The scheduler triggering it:&lt;/strong&gt; a pure interval (every 1 hour), with no wall-clock time at all, so no timezone ambiguity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The downstream BI copy job's schedule:&lt;/strong&gt; this is where confusion crept in; the scheduling surface here allowed an offset (UTC+5) matched to a different reference city than our own team's location, purely coincidentally sharing the same numeric offset. Functionally correct, but worth double-checking explicitly rather than assuming a label matches your actual timezone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; audit every single scheduling surface in the pipeline individually for its timezone assumption; don't assume consistency just because the data underneath is uniformly UTC.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 7: Testing With Realistic, Synthetic Traffic
&lt;/h2&gt;

&lt;p&gt;Once the pipeline was live, we needed realistic-looking data to validate the dashboard; real usage takes time to accumulate, and manually clicking through a form a dozen times doesn't produce enough volume or variety to sanity-check charts.&lt;/p&gt;

&lt;p&gt;We used a headless browser automation tool (Playwright) to drive many independent, randomized simulated visitors against a local dev instance of the actual site, not mocked requests, a real browser running real frontend code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each simulated visitor got its own isolated browser context (own cookies, own &lt;code&gt;sessionStorage&lt;/code&gt;), so distinct session IDs, not one session repeated.&lt;/li&gt;
&lt;li&gt;Agents were launched with staggered random start times across a real multi-minute window, so timestamps spread naturally instead of clustering into one instant.&lt;/li&gt;
&lt;li&gt;At every decision point, each agent randomly chose whether to proceed, which option to pick, and whether to complete or abandon the funnel, deliberately unbiased, no hardcoded "always pick X".&lt;/li&gt;
&lt;li&gt;Interactions targeted the literal visible text of real UI options rather than brittle CSS selectors, since visible copy is far more stable than internal class names.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One bug worth mentioning even here: our bot initially had a near-zero success rate clicking the main call-to-action button. The cause: the button's exact text also appeared elsewhere on the page as an unrelated heading, and a plain text-based click locator sometimes matched the decorative heading rather than the actual interactive button. The fix was switching to a role-scoped locator (matching only genuine &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt; elements with that accessible name), which eliminated the ambiguity entirely. &lt;strong&gt;Lesson:&lt;/strong&gt; when automating clicks by visible text, scope by element role/type too, duplicate text elsewhere on a real page is more common than you'd expect.&lt;/p&gt;




&lt;h2&gt;
  
  
  Dashboard
&lt;/h2&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%2Fcywsl0q4bf3kno8bm8wy.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%2Fcywsl0q4bf3kno8bm8wy.png" alt=" " width="799" height="421"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Cost, Honestly
&lt;/h2&gt;

&lt;p&gt;At the traffic volumes this post describes (a nonprofit donation platform, not a high-traffic consumer app), the actual monthly AWS cost for this entire pipeline, API Gateway, Lambda, Firehose, S3, Glue, Athena, came out to well under a few dollars a month, even accounting for the services whose free tier is a permanent "always free" tier regardless of account age (one of a small handful of AWS services with that property, distinct from the more commonly known 12-months-only free tier).&lt;/p&gt;

&lt;p&gt;The main cost lever, by far, is Athena's per-query, per-terabyte-scanned pricing, and partition filtering (Part 2) is what keeps that number small. Skip partition filtering at real scale, and this is exactly where a bill quietly grows.&lt;/p&gt;




&lt;h2&gt;
  
  
  What We'd Tell a Team Building This From Scratch
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Verify your identity provider's anonymous-access posture&lt;/strong&gt; before designing anything that assumes browsers can hold direct cloud credentials. It's a common, easy-to-miss blocker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A proxy Lambda in front of your ingestion service isn't just a workaround&lt;/strong&gt;; it's arguably the better design anyway, since it gives you a validation and rate-limiting choke point you wouldn't otherwise have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partition your data by time from day one, and always filter by it.&lt;/strong&gt; The cost and performance difference is anything but subtle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You don't need a full ETL pipeline just to aggregate data.&lt;/strong&gt; A query engine that can aggregate on demand (Athena, and equivalents on other clouds) removes the need for a separate transform step; build the heavier pipeline only when you hit a genuine, specific wall (BI tool connector limitations, or real performance ceilings), not preemptively.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Case sensitivity in JSON parsing libraries is a silent, not a loud, failure mode.&lt;/strong&gt; Test with real mixed-case field names early.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero-copy/Direct-Lake-style BI engines require real, physically correct column types&lt;/strong&gt;; you cannot fix a type mismatch downstream in the BI tool alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A funnel that doesn't monotonically shrink is a bug, not a chart formatting issue.&lt;/strong&gt; Trace it to root cause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give any auto-generated, globally-unique resource name (like an S3 bucket) a stable, fixed alias&lt;/strong&gt; if anything external depends on referencing it; account ID suffixes are a simple, reliable way to do this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Synthetic test traffic is worth automating properly.&lt;/strong&gt; But automate the clicking robustly (scope by role, not just text), or you'll draw the wrong conclusions from your own test data.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;This pipeline is fully serverless, requires no dedicated infrastructure team to operate, and as shown above, costs a genuinely small amount of money at moderate scale. If your team is facing a similar "we have no idea how people use our product" problem, this stack (or an equivalent on whichever cloud you're on) is a reasonable, low-commitment way to get real answers.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>clickstream</category>
      <category>awsbigdata</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>Building Distributed Tracing for a Data-Intensive Platform: The X-Ray Journey on AWS</title>
      <dc:creator>M. Abdullah Bin Aftab</dc:creator>
      <pubDate>Fri, 10 Jul 2026 06:27:21 +0000</pubDate>
      <link>https://dev.to/heighter/building-distributed-tracing-for-a-data-intensive-platform-the-x-ray-journey-on-aws-3dpi</link>
      <guid>https://dev.to/heighter/building-distributed-tracing-for-a-data-intensive-platform-the-x-ray-journey-on-aws-3dpi</guid>
      <description>&lt;h2&gt;
  
  
  The Problem We Started With
&lt;/h2&gt;

&lt;p&gt;Our donation platform runs on 50+ AWS Lambda functions, handling everything from &lt;strong&gt;Stripe/PayPal&lt;/strong&gt; payments to &lt;strong&gt;DB&lt;/strong&gt; writes to SQS-based background processing (queue consolidation, tax receipts, user events, and more). When something went wrong in production, we had no easy way to answer basic questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which specific function failed, and why?&lt;/li&gt;
&lt;li&gt;Did a donation actually reach MongoDB, or did it fail silently?&lt;/li&gt;
&lt;li&gt;When a webhook queues a background job, does that job actually run, and how long does the whole chain take?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We needed &lt;strong&gt;distributed tracing&lt;/strong&gt;: a way to see, end-to-end, what happens when a request flows through our system. &lt;strong&gt;AWS X-Ray&lt;/strong&gt; is the natural tool for this on &lt;strong&gt;Lambda&lt;/strong&gt;. This is the story of getting it working, including the two hardest problems we hit: &lt;strong&gt;"Unknown Host" nodes&lt;/strong&gt; polluting every trace, and &lt;strong&gt;SQS producer→consumer visibility&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt #1: ADOT (OpenTelemetry) - The Modern Path That Wasn't Ready
&lt;/h2&gt;

&lt;p&gt;AWS's recommended modern approach for Lambda tracing is the ADOT (AWS Distro for OpenTelemetry) Lambda layer. It auto-instruments HTTP, AWS SDK calls, and more, with zero code changes, attach a layer and set an environment variable.&lt;/p&gt;

&lt;p&gt;We attached it. Every function immediately started crashing on cold start with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Runtime.CallbackHandlerDeprecated: AWS Lambda has removed support for
callback-based function handlers starting with Node.js 24.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Investigation confirmed this wasn't a config mistake, it's a real, unresolved AWS limitation. Node.js 24 dropped support for callback-style Lambda handlers, and ADOT's Node.js wrapper hasn't been updated to reflect this change. I found open &lt;strong&gt;GitHub&lt;/strong&gt; issues on &lt;code&gt;aws-observability/aws-otel-lambda&lt;/code&gt; from other teams hitting the same wall, with no fix timeline from AWS. Their own docs list Node 22 as the latest officially supported runtime for ADOT.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decision:&lt;/strong&gt; stick with Node.js 24 (rather than downgrade) and use the classic &lt;strong&gt;AWS X-Ray SDK&lt;/strong&gt; (via AWS Lambda Powertools' &lt;code&gt;Tracer&lt;/code&gt;) instead. Less automatic, but stable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt #2: Powertools Tracer, Getting the Basics Right
&lt;/h2&gt;

&lt;p&gt;We rebuilt tracing using &lt;code&gt;@aws-lambda-powertools/tracer&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tracer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Tracer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;serviceName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;XYZ&amp;gt;_Backend&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;withMetrics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handlerName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handlerLogic&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;segment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSegment&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;subsegment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;addNewSubsegment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handlerName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handlerLogic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;subsegment&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;addError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;subsegment&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;close&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;Every one of our &lt;strong&gt;~50+&lt;/strong&gt; Lambda handlers got wrapped with &lt;code&gt;withMetrics&lt;/code&gt;, giving each function its own named trace with duration and error status. Combined with enabling &lt;code&gt;tracingConfig: { mode: "Active" }&lt;/code&gt; on every function in our CDK backend definition, this gave us basic, working, per-function tracing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem #1: "Unknown Host"
&lt;/h2&gt;

&lt;p&gt;Once external calls (MongoDB, Stripe, PayPal) started happening inside these traced functions, X-Ray started showing a strange red node labeled &lt;strong&gt;"Unknown host"&lt;/strong&gt; for outbound network activity with no way to tell what it actually was.&lt;/p&gt;

&lt;h3&gt;
  
  
  Root Cause
&lt;/h3&gt;

&lt;p&gt;Digging into the Powertools Tracer source code revealed the culprit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Tracer.js, inside the constructor&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isTracingEnabled&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;captureHTTPsRequests&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;captureHTTPsGlobal&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;instrumentFetch&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;captureHTTPsRequests&lt;/code&gt; &lt;strong&gt;defaults to &lt;code&gt;true&lt;/code&gt;&lt;/strong&gt;. This means Powertools Tracer was &lt;em&gt;silently&lt;/em&gt; patching Node's core &lt;code&gt;http&lt;/code&gt;/&lt;code&gt;https&lt;/code&gt; module on every cold start, the exact same kind of monkey-patching that's known to misbehave on Node.js 24's updated internals. It wasn't crashing outright, but it was producing broken, unlabeled subsegments instead of properly-tagged ones (or in some cases, nothing at all).&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix
&lt;/h3&gt;

&lt;p&gt;Two changes, both verified against the actual library source before applying:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Turn off the broken auto-instrumentation:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tracer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Tracer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;serviceName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;XYZ_Backend&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;captureHTTPsRequests&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Manually wrap external calls, and mark them correctly for the trace map:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;traceExternalCall&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;segment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSegment&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;subsegment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addNewSubsegment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;subsegment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;remote&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// &amp;lt;- tells X-Ray to render this as its own node&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;subsegment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;subsegment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&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;The &lt;code&gt;namespace = "remote"&lt;/code&gt; line matters more than it looks; it's literally the same flag the AWS X-Ray SDK's own HTTP patcher sets internally when it recognizes an external call. Without it, a subsegment just sits in the timeline list; with it, X-Ray draws it as a proper node on the trace map.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making It Automatic, Not Manual-Per-Function
&lt;/h3&gt;

&lt;p&gt;Rather than wrapping every Mongo/Stripe/PayPal call across 50+ files by hand, we centralized the fix into the few shared places all those calls already flow through:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MongoDB&lt;/strong&gt;: wrapped once, inside the shared &lt;code&gt;connectToDb()&lt;/code&gt; helper. Every function that connects to Mongo gets a &lt;code&gt;MongoDB-Connect&lt;/code&gt; node automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stripe&lt;/strong&gt;: the Stripe SDK exposes native &lt;code&gt;request&lt;/code&gt;/&lt;code&gt;response&lt;/code&gt; events. We hooked into those once, inside our shared &lt;code&gt;initializeStripe()&lt;/code&gt; factory:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;  &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;request&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;subsegment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSegment&lt;/span&gt;&lt;span class="p"&gt;()?.&lt;/span&gt;&lt;span class="nf"&gt;addNewSubsegment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;api.stripe.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;subsegment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;remote&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;openSubsegments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request_start_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;subsegment&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;response&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;subsegment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;openSubsegments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request_start_time&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;subsegment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addErrorFlag&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;subsegment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&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;ul&gt;
&lt;li&gt;
&lt;strong&gt;PayPal / Zoho / Coda / any HTTP call via axios&lt;/strong&gt; a single global interceptor on the shared &lt;code&gt;axios&lt;/code&gt; instance, since every one of these services happens to call out via axios:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;  &lt;span class="nx"&gt;axios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;interceptors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;subsegment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSegment&lt;/span&gt;&lt;span class="p"&gt;()?.&lt;/span&gt;&lt;span class="nf"&gt;addNewSubsegment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;subsegment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;remote&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;xraySubsegment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;subsegment&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;axios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;interceptors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;xraySubsegment&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&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="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;xraySubsegment&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&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;Result: &lt;strong&gt;zero code changes needed per new function&lt;/strong&gt;, as long as it reuses these shared utilities. "Unknown host" nodes were replaced with correctly-labeled &lt;code&gt;MongoDB-Connect&lt;/code&gt;, &lt;code&gt;api.stripe.com&lt;/code&gt;, &lt;code&gt;api-m.paypal.com&lt;/code&gt;, etc.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem #2: Seeing SQS Producer → Consumer Flows
&lt;/h2&gt;

&lt;p&gt;Our architecture is heavily queue-based: a webhook function does its work, then drops a message on an SQS queue; a separate Lambda, triggered by that queue, picks it up and continues (e.g. writing to MongoDB). We wanted to see that whole chain as one connected story in X-Ray.&lt;/p&gt;

&lt;h3&gt;
  
  
  First (Wrong) Conclusion
&lt;/h3&gt;

&lt;p&gt;Our first attempt used the classic pattern of reading the SQS message's &lt;code&gt;AWSTraceHeader&lt;/code&gt; attribute and manually overwriting the consumer's segment &lt;code&gt;trace_id&lt;/code&gt;/&lt;code&gt;parent_id&lt;/code&gt; to match the producer's. We tested it directly, injected a known fake trace ID into a synthetic SQS event, invoked the consumer, and checked what trace ID it reported.&lt;/p&gt;

&lt;p&gt;It didn't work. The consumer always reported its own, fresh trace ID, regardless of what we injected. We confirmed this at the API level too, using &lt;code&gt;aws xray get-trace-graph&lt;/code&gt; on the producer's trace ID; the consumer never appeared in the graph.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; on Lambda, the top-level trace ID is assigned by the AWS Lambda platform &lt;em&gt;before&lt;/em&gt; your code runs. Application code can't override it after the fact, that door is locked from the platform side. This is different from services like ECS/EC2, where the X-Ray SDK itself creates the root segment and &lt;em&gt;can&lt;/em&gt; be told what trace ID to use.&lt;/p&gt;

&lt;p&gt;We initially documented this as "SQS producer→consumer tracing isn't possible on Node 24 without ADOT" and moved on.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Correction
&lt;/h3&gt;

&lt;p&gt;I later discovered we'd tested the wrong thing. AWS has a &lt;strong&gt;separate mechanism&lt;/strong&gt; "linked traces" that connects two distinct trace IDs together &lt;em&gt;for console viewing&lt;/em&gt;, without merging them into one trace ID. Opening the consumer's trace in the X-Ray console showed a banner: &lt;strong&gt;"This trace is part of a linked set of traces"&lt;/strong&gt;, with the producer trace displayed alongside it, connected.&lt;/p&gt;

&lt;p&gt;This worked because the &lt;code&gt;AWSTraceHeader&lt;/code&gt; was already being correctly propagated onto the SQS message (this part worked from the start) X-Ray's console uses that header to link the two traces together for display, even though they remain separate IDs under the hood.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making the Queue Itself Visible
&lt;/h3&gt;

&lt;p&gt;One more piece was missing: our SQS client wasn't wrapped with any tracing at all, so the actual "send to queue" step was invisible, and the queue never appeared as its own node on the trace map. The fix was a single line, in the one shared place all our queue sends flow through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// common/services/messageQueue.service.ts&lt;/span&gt;
&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messageQueue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;captureAWSv3Client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SQSClient&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;captureAWSv3Client&lt;/code&gt; hooks into the AWS SDK v3's own middleware stack (not Node's core modules), so it's unaffected by the Node 24 monkey-patching issues entirely. After this change, the Trace Map showed the SQS queue as an explicit node &lt;code&gt;https://sqs.../XYZSaveUsersQueue.fifo&lt;/code&gt; sitting visually between the producer and consumer functions, with the queue's real URL.&lt;/p&gt;

&lt;h3&gt;
  
  
  End Result
&lt;/h3&gt;

&lt;p&gt;For a real signup flow (&lt;code&gt;postConfirmationFunction&lt;/code&gt; → SQS → &lt;code&gt;saveUsersFunction&lt;/code&gt;), the Trace Map now shows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client → postConfirmation (Lambda) → SQS Queue → saveUsers (Lambda) → MongoDB-Connect
                                                                     → MongoDB-FindUser
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the linked-traces banner connecting the two Lambda traces, and the MongoDB node showing "Ok 100%" with zero faults a fully connected, correctly labeled, verified-working picture of the whole flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Verify library defaults, don't assume them.&lt;/strong&gt; The "Unknown host" bug existed because a &lt;code&gt;true&lt;/code&gt; default was silently active the whole time. Reading the actual source code (&lt;code&gt;Tracer.js&lt;/code&gt;) settled it in minutes, versus hours of guessing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test claims empirically, especially "automatic" ones.&lt;/strong&gt; AWS's own documentation implied SQS linking "just works" for Lambda consumers. It doesn't not the way we first tested it. The real feature (linked traces) exists, but it's a different mechanism than the docs' wording suggested.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Centralize instrumentation, don't scatter it.&lt;/strong&gt; Wrapping ~5 shared utility files (Mongo connect, Stripe factory, axios instance, SQS service) gave us tracing coverage across 50+ functions, with zero risk of new functions being "forgotten." The only manual step left is wrapping each new handler's export in &lt;code&gt;withMetrics&lt;/code&gt; a single line, documented in our dev guide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node.js version currency has real, unglamorous costs.&lt;/strong&gt; Being on the newest Node runtime meant losing access to AWS's newest tracing tooling (ADOT) for the time being. That's a real trade-off, not a bug to route around.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Production Considerations Going Forward
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sampling:&lt;/strong&gt; X-Ray doesn't trace every request by default (1/sec + 5% of the rest). What we tested manually (1 request at a time) always gets sampled; production traffic won't be 100% visible without tuning sampling rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alarms over eyeballs:&lt;/strong&gt; the Trace Map is a "glance and check" tool, not something to watch live. Real usage means CloudWatch Alarms on fault rate/latency, with traces used for investigation &lt;em&gt;after&lt;/em&gt; an alarm fires.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Annotations for searchability:&lt;/strong&gt; to find one specific customer's failed transaction later, we plan to add searchable annotations (e.g. order ID) to segments something to build next.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>data</category>
      <category>systemdesign</category>
      <category>node</category>
    </item>
    <item>
      <title>Amazon Q CLI: Rock Paper Scissor with Python 🕹️</title>
      <dc:creator>M. Abdullah Bin Aftab</dc:creator>
      <pubDate>Wed, 04 Jun 2025 04:35:40 +0000</pubDate>
      <link>https://dev.to/heighter/amazon-q-cli-rock-paper-scissor-with-python-2o9f</link>
      <guid>https://dev.to/heighter/amazon-q-cli-rock-paper-scissor-with-python-2o9f</guid>
      <description>&lt;p&gt;In this blog post, I'll walk you through my journey of creating a fun &lt;strong&gt;Rock Paper Scissors&lt;/strong&gt; game using Python and Pygame, with the assistance of &lt;strong&gt;Amazon Q CLI&lt;/strong&gt;. I'll cover everything from setting up the development environment to implementing game mechanics and generating custom assets.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔧 Installing Amazon Q CLI on macOS
&lt;/h2&gt;

&lt;p&gt;Amazon Q CLI is a powerful tool that helped me throughout the development process. Here's how I installed it on my macOS:&lt;/p&gt;

&lt;p&gt;First install the AWS CLI using Homebrew:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;brew install awscli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I configured my AWS credentials:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;aws configure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, I installed the Amazon Q CLI:&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 amazon-q-cli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After installation, I verified it was working:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;q --version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I started using Amazon Q by running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;q chat
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the chat, I asked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Can you help me structure a Rock Paper Scissors game using Pygame?

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Amazon Q CLI became my coding companion, helping me generate code snippets, debug issues, and even create project documentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  🎮 Designing the Game Structure
&lt;/h2&gt;

&lt;p&gt;Based on Amazon Q's suggestions, I designed the game with three main states:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Menu State:&lt;/strong&gt; The main menu where players can start the game.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Playing State:&lt;/strong&gt; Where players select their move (Rock, Paper, or Scissors).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Result State:&lt;/strong&gt; Displaying the outcome and allowing players to play again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I created the main game file structure with Amazon Q's help:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;q chat
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I asked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Can you help me create a main.py file for my Rock Paper Scissors game with state management?

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  🎯 Implementing Game Mechanics
&lt;/h2&gt;

&lt;p&gt;For the core game mechanics, I needed to implement:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Player choice selection&lt;/li&gt;
&lt;li&gt;Computer random choice generation&lt;/li&gt;
&lt;li&gt;Winner determination logic&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  🎁 Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Building this Rock Paper Scissors game was a fun and educational experience. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Amazon Q CLI significantly accelerated my development process by:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Providing code snippets and structure&lt;/li&gt;
&lt;li&gt;Helping debug issues quickly&lt;/li&gt;
&lt;li&gt;Generating documentation&lt;/li&gt;
&lt;li&gt;Offering best practices and optimization tips&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The final game includes:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An intuitive user interface&lt;/li&gt;
&lt;li&gt;Smooth animations and transitions&lt;/li&gt;
&lt;li&gt;Visual feedback with particle effects&lt;/li&gt;
&lt;li&gt;Custom-generated game assets&lt;/li&gt;
&lt;li&gt;Multiple game states for a complete experience&lt;/li&gt;
&lt;li&gt;If you're developing games or any software project, I highly recommend giving Amazon Q CLI a try. It's like having an expert developer by your side, ready to help whenever you need it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;🕹️ To run the game yourself:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git clone https://https://github.com/heighterses/rock-paper-scissor-Q-CLI&lt;/code&gt;&lt;br&gt;
&lt;code&gt;cd rock-paper-scissor-Q-CLI&lt;/code&gt;&lt;br&gt;
&lt;code&gt;pip install pygame&lt;/code&gt;&lt;br&gt;
&lt;code&gt;python main.py&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Enjoy playing Rock Paper Scissors! 🎮&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>ai</category>
      <category>amazonqcli</category>
      <category>amazondeveloper</category>
    </item>
    <item>
      <title>Case Study: Creating an ETL Data Pipeline using AWS Services - Real-World Problem</title>
      <dc:creator>M. Abdullah Bin Aftab</dc:creator>
      <pubDate>Fri, 06 Dec 2024 18:04:47 +0000</pubDate>
      <link>https://dev.to/heighter/case-study-creating-an-etl-data-pipeline-using-aws-services-real-world-problem-1e40</link>
      <guid>https://dev.to/heighter/case-study-creating-an-etl-data-pipeline-using-aws-services-real-world-problem-1e40</guid>
      <description>&lt;h2&gt;
  
  
  Architecture
&lt;/h2&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%2F4qmojb210e1coimc4d29.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%2F4qmojb210e1coimc4d29.png" alt=" " width="800" height="463"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Overview
&lt;/h2&gt;

&lt;p&gt;This ETL pipeline leverages several AWS services to fetch, process, and store YouTube videos with translated subtitles. The core components include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AWS Lambda for processing video and audio&lt;/li&gt;
&lt;li&gt;Step Functions for Orchestrating Workflows&lt;/li&gt;
&lt;li&gt;S3 Buckets for storing raw and processed data&lt;/li&gt;
&lt;li&gt;AWS Transcribe for converting audio to text&lt;/li&gt;
&lt;li&gt;AWS Translate for translating the text into desired languages&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Pipeline Steps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Fetching Video from YouTube&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Video is fetched from YouTube and sent to an initial Lambda Function.&lt;/li&gt;
&lt;li&gt;This function splits the video into separate audio and video files.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Step Functions Orchestration&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AWS Step Functions orchestrate all the following processes.&lt;/li&gt;
&lt;li&gt;The workflow controls each step, passing data between services and managing the pipeline flow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Storing Raw Video and Audio&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Audio and video files are stored separately here.&lt;/li&gt;
&lt;li&gt;The bucket sends an email notification to confirm the files were successfully stored.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Merging Video and Audio Files&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;This Lambda function combines the audio and video files into a single file.&lt;/li&gt;
&lt;li&gt;Error Handling: If merging fails, the pipeline stores the failed files in an Error Bucket.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Transcribing Audio to Text&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The merged video’s audio is sent to AWS Transcribe to convert audio into text.&lt;/li&gt;
&lt;li&gt;The resulting text is passed to the next Lambda function.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Proofreading Transcribed Text&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;This Lambda function checks the transcription for accuracy and readability.&lt;/li&gt;
&lt;li&gt;If the text is poor quality, it can be flagged for manual review or re-transcription.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Translating Text into Target Language&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The proofread text is sent to AWS Translate to convert it into a chosen language (e.g., Arabic, Italian, or Spanish).&lt;/li&gt;
&lt;li&gt;The translated text is then passed to the next Lambda function.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Generating Subtitles and Merging Paragraphs&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;This function formats the translated text as subtitles and merges paragraphs if needed.&lt;/li&gt;
&lt;li&gt;The final file is prepared for storage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Storing Processed Video with Subtitles&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The fully processed video with subtitles in the desired language is stored here.&lt;/li&gt;
&lt;li&gt;This is the final output location where the processed video is accessible.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Error Handling
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Error Bucket for Failed Merges:&lt;/strong&gt; If audio and video files fail to merge, the Lambda Merge Function sends them to a designated Error Bucket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transcription and Proofreading Quality Check:&lt;/strong&gt; Poor transcription quality detected by the Proofread Function can trigger a flag for manual review or re-transcription.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Notifications
&lt;/h2&gt;

&lt;p&gt;**Email Notifications **for File Arrival in S3:After the raw video and audio files are stored in the S3 Raw Video and Audio Bucket, an email notification is sent to confirm successful storage.&lt;/p&gt;




&lt;h2&gt;
  
  
  Benefits of This Pipeline
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Automation:&lt;/strong&gt; Streamlines the entire video processing workflow without manual intervention.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scalability:&lt;/strong&gt; AWS services like Lambda and Step Functions allow the pipeline to handle many videos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Language Support:&lt;/strong&gt; AWS Translate enables easy translation to multiple languages, broadening the video’s reach.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error Management:&lt;/strong&gt; Dedicated error-handling buckets and flags ensure issues are logged and handled efficiently.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>aws</category>
      <category>dataengineering</category>
      <category>data</category>
      <category>cloudcomputing</category>
    </item>
    <item>
      <title>How to Import Pandas(library) in AWS Lambda Functions - AWS Lambda Layers</title>
      <dc:creator>M. Abdullah Bin Aftab</dc:creator>
      <pubDate>Wed, 16 Oct 2024 19:13:43 +0000</pubDate>
      <link>https://dev.to/heighter/how-to-import-pandaslibrary-in-aws-lambda-functions-aws-lambda-layers-1oen</link>
      <guid>https://dev.to/heighter/how-to-import-pandaslibrary-in-aws-lambda-functions-aws-lambda-layers-1oen</guid>
      <description>&lt;p&gt;Imagine you need to run a Python script on the AWS Lambda function and you get this error 🔻&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "errorMessage": "Unable to import module 'lambda_function': No module named 'pandas',
  "errorType": "Runtime.ImportModuleError"
  ...
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Don't worry this is a common error and I am not going to make this long&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How do you import Pandas in AWS Lambda Functions?
&lt;/h2&gt;

&lt;p&gt;There are several ways but I am going to give you the easiest way to import pandas in AWS Lambda Function is to add Lambda Layer 🥪&lt;/p&gt;

&lt;h4&gt;
  
  
  What is AWS Lambda Layer?
&lt;/h4&gt;

&lt;p&gt;It is a 🧀 cheese layer in Lambda Function containing additional code like libraries, dependencies, etc.&lt;/p&gt;

&lt;h5&gt;
  
  
  In Simple Words
&lt;/h5&gt;

&lt;p&gt;AWS Lambda Layers are like building blocks for your functions. &lt;strong&gt;Imagine&lt;/strong&gt; you need extra tools (like the Pandas library) to complete a project.&lt;/p&gt;

&lt;p&gt;Instead of packing all those tools inside every single project (which wastes space and time), AWS allows you to create layers of tools (libraries, dependencies, or shared code). &lt;/p&gt;

&lt;p&gt;These layers sit outside your main function but are always available when your function needs them.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;In short, Lambda Layers help you:&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Separate the main logic from the extra libraries to save space in your code. You can reuse libraries and code across multiple Lambda functions.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Easily update or manage your dependencies without changing your core function code.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Think of layers as an extra storage box attached to your Lambda function, holding everything your function needs to work smoothly. You can stack multiple layers on your function without cluttering your main code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Steps to add Lambda Function Layer and Import Pandas
&lt;/h2&gt;

&lt;p&gt;It takes only 3 steps to run Pandas in your Lambda Function successfully&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 - Open Lambda Function through your AWS Management Console
&lt;/h3&gt;

&lt;p&gt;As you can see we have an option &lt;strong&gt;Layers&lt;/strong&gt; under the name of our Lambda Function, in my case, it's &lt;em&gt;"import-pandas-function"&lt;/em&gt; and the Layers count is &lt;strong&gt;0&lt;/strong&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%2Fabn0rsvdm5tsuld3c78c.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%2Fabn0rsvdm5tsuld3c78c.png" alt=" " width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2- Add Script in your AWS Lambda Function
&lt;/h3&gt;

&lt;p&gt;This step is further divided into two steps because we need to add a Python script that contains some Pandas code and write a test event in JSON to verify whether the code is running correctly.&lt;/p&gt;

&lt;h4&gt;
  
  
  2.1 - Add Python script - you can copy this code 🡻
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import json
import pandas as pd

def lambda_handler(event, context):
    data = event.get('data', [])

    df = pd.DataFrame(data)

    if not df.empty:
        mean_value = df['column_name'].mean()

        result = {
            "mean_value": mean_value,
            "data_shape": df.shape,
            "summary": df.describe().to_dict()
        }
    else:
        result = {
            "message": "Empty DataFrame"
        }

    # Return the response
    return {
        'statusCode': 200,
        'body': json.dumps(result)
    }

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  2.2 - Add Test script in Json in the test tab - you can copy this code 🡻
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "data": [
    {"column_name": 10, "other_column": "A"},
    {"column_name": 20, "other_column": "B"},
    {"column_name": 30, "other_column": "C"},
    {"column_name": 40, "other_column": "D"}
  ]
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Press the test button you probably got the 🔴error:-&lt;br&gt;
&lt;code&gt;"errorMessage": "Unable to import module 'lambda_function': No module named 'pandas',&lt;br&gt;
  "errorType": "Runtime.ImportModuleError"&lt;br&gt;
  ...&lt;/code&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 3 - Add AWS Lambda Layer to Successfully run the Pandas in your  Code
&lt;/h2&gt;

&lt;p&gt;Scroll down to your Lambda Function, you probably can see the "&lt;strong&gt;Layers&lt;/strong&gt;" separate section at the end of the page&lt;br&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%2F17grr4nr4oiwg2q0dq4v.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%2F17grr4nr4oiwg2q0dq4v.png" alt=" " width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h4&gt;
  
  
  3.1 - Click "Add a Layer"
&lt;/h4&gt;

&lt;p&gt;After Clicking the "Add a Layer" you can see the page which has a couple of sections "Function runtime settings" and "Choose a layer"&lt;br&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%2Fhnejuqy0vir917to8vi0.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%2Fhnejuqy0vir917to8vi0.png" alt=" " width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h4&gt;
  
  
  3.2 - Click "AWS layers"
&lt;/h4&gt;

&lt;p&gt;You can see three options in the "Choose a layer" section click the "AWS layers".&lt;br&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%2Frwp9ns0t7o0p5nx3ta2w.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%2Frwp9ns0t7o0p5nx3ta2w.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h4&gt;
  
  
  3.3 - Choose "AWS layers"
&lt;/h4&gt;

&lt;p&gt;After selecting the AWS layers you can see the dropdown under "AWS layers".&lt;br&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%2Fkbzeshtplbmnvktjt0ch.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%2Fkbzeshtplbmnvktjt0ch.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h4&gt;
  
  
  3.4 - Choose "AWS layers" and "Version"
&lt;/h4&gt;

&lt;p&gt;In a dropdown of "AWS layers" select -&amp;gt; &lt;strong&gt;AWSSDKPandas-Python312&lt;/strong&gt;&lt;br&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%2Fg2u5k77yik3mdmsg64ba.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%2Fg2u5k77yik3mdmsg64ba.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
In a dropdown of "Version" select -&amp;gt; &lt;strong&gt;13(select the most one)&lt;/strong&gt;&lt;br&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%2Fcjyama9hl5yfwnb59c7q.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%2Fcjyama9hl5yfwnb59c7q.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
click the "Add" button&lt;br&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%2Fw7h2f3afl3pf8t6dq9yl.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%2Fw7h2f3afl3pf8t6dq9yl.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h4&gt;
  
  
  3.5 - Make sure the "Function Overview"
&lt;/h4&gt;

&lt;p&gt;When your page is directed to the function overview you can see the layer is added below the function name "import-pandas-function"&lt;br&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%2F34yb2fx9lk2d9bygh30c.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%2F34yb2fx9lk2d9bygh30c.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 4 - Test the Function
&lt;/h2&gt;

&lt;p&gt;You've successfully got the Response &lt;em&gt;"statusCode": 200&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;{
  "statusCode": 200,
  "body": "{\"mean_value\": 25.0, \"data_shape\": [4, 2], \"summary\": {\"column_name\": {\"count\": 4.0, \"mean\": 25.0, \"std\": 12.909944487358056, \"min\": 10.0, \"25%\": 17.5, \"50%\": 25.0, \"75%\": 32.5, \"max\": 40.0}}}"
}

&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%2Fmyvwqocbt1a9k6wkwuwn.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%2Fmyvwqocbt1a9k6wkwuwn.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Keep Coding 😎&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>aws</category>
      <category>lambda</category>
      <category>python</category>
      <category>cloudcomputing</category>
    </item>
  </channel>
</rss>
