<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Nwachukwu Chibuike on Medium]]></title>
        <description><![CDATA[Stories by Nwachukwu Chibuike on Medium]]></description>
        <link>https://medium.com/@nwachukwuchibuike?source=rss-38b4b5e55906------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*RSFYxfrQj4rXx0nO1rSUNA.jpeg</url>
            <title>Stories by Nwachukwu Chibuike on Medium</title>
            <link>https://medium.com/@nwachukwuchibuike?source=rss-38b4b5e55906------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sun, 15 Nov 2026 20:11:21 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@nwachukwuchibuike/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com/" rel="hub"/>
        <item>
            <title><![CDATA[Beyond Boolean Toggles: Data-Rich Feature Flags with AWS AppConfig and Python CDK]]></title>
            <link>https://towardsaws.com/beyond-boolean-toggles-data-rich-feature-flags-with-aws-appconfig-and-python-cdk-536fa46bc0ad?source=rss-38b4b5e55906------2</link>
            <guid isPermaLink="false">https://medium.com/p/536fa46bc0ad</guid>
            <category><![CDATA[aws-appconfig]]></category>
            <category><![CDATA[python]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[aws-lambda]]></category>
            <category><![CDATA[aws-cdk]]></category>
            <dc:creator><![CDATA[Nwachukwu Chibuike]]></dc:creator>
            <pubDate>Wed, 08 Jul 2026 17:02:04 GMT</pubDate>
            <atom:updated>2026-07-08T17:02:04.971Z</atom:updated>
            <content:encoded><![CDATA[<p>Many developers view AWS AppConfig solely as a basic feature flag platform for toggling boolean values on or off. However, most applications demand complex, data-rich parameters. To solve this, we leverage AppConfig Free-form Configurations to deliver structured, fully customized JSON payloads directly to our application.</p><p>In this article, we use the AWS CDK for Python to build a high-performance architecture powered by AWS AppConfig, API Gateway, and Lambda. You will learn how to leverage these AWS services to safely update advanced application configurations and operational parameters live in production, with zero cold starts, and without ever redeploying code.</p><h3>Prerequisites</h3><ul><li>AWS account</li><li>AWS CLI setup and configured with appropriate credentials</li><li><a href="https://cdkworkshop.com/15-prerequisites.html">AWS CDK</a> set up.</li><li><a href="https://www.python.org/downloads/">Python3</a></li></ul><h3>Case Study</h3><p>Imagine managing a high-volume GenAI orchestrator that stores operational parameters like token limits and active model strings as static Lambda environment variables. During a viral traffic spike, a sudden surge of massive prompts can skyrocket costs and trigger upstream 429 rate-limit errors. Fixing this by modifying variables or waiting over 10 minutes for a CI/CD pipeline to redeploy code causes severe downtime, while basic boolean feature flags lack the data complexity needed to tweak these parameters on the fly.</p><p>Your proposed solution is:</p><p>To gain real-time control without code deployments, you move all dynamic metrics into AWS AppConfig Free-form configurations, which act as a serverless switchboard delivering structured JSON payloads.</p><p>You propose attaching the AWS AppConfig Agent Lambda Extension via the Python CDK to the Lambda function; this way, the function gains an isolated local server on port 2772 that caches configurations automatically. Lambda fetches active rules instantly through a localhost HTTP request, bypassing slow, expensive AWS SDK calls entirely.</p><p>When a cost or rate-limiting emergency occurs, you simply update the remote AppConfig JSON profile to reduce token caps and swap to cheaper fallback models. The background sidecar pulls these updates automatically, allowing warm Lambda functions to inherit strict new guardrails in under two seconds. This creates a live feedback loop that lets your application layer safely self-correct mid-execution with zero cold starts, zero downtime, and complete structural isolation.</p><h3>Proposed Architecture</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/631/1*InCXsSCuVGseFtRzEZJyhw.png" /></figure><p><strong>We can see that:</strong></p><ol><li>The User invokes the Lambda function via the API Gateway</li><li>The Lambda function uses the AWS AppConfig Agent, which is attached as a Lambda layer, to locally pull the latest cache from AWS AppConfig. We don&#39;t need to worry about the internals, as that is fully handled by the layer.</li></ol><h4>Why the need for this Lambda Layer Sidecar approach?</h4><p>Storing configurations inside Lambda code requires redeployments, while querying remote sources like DynamoDB or directly calling AppConfig introduces high network latency.</p><p>The sidecar layer acts as an automated local cache. When your code requests an endpoint, the agent instantly serves the data with zero network overhead. If the configuration expires, the agent asynchronously fetches updates from AppConfig in the background. Because calls are handled locally, you can poll the cache as often as needed without triggering external API limits or slowing down your execution thread.</p><h3>What is AWS AppConfig?</h3><p><a href="https://docs.aws.amazon.com/appconfig/latest/userguide/what-is-appconfig.html"><strong>AWS AppConfig</strong></a> is a capability of AWS Systems Manager that enables developers to safely modify application behavior in production without redeploying code. By utilizing feature flags and dynamic configurations, you can roll out features gradually, monitor performance, and instantly update parameters like rate limits or logging levels to mitigate live operational issues.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vwynB3fkkqE6Q90K5o6pYQ.png" /></figure><p>To manage this workflow, AppConfig uses structural containers. An <strong>Application</strong> serves as your central namespace, housing <strong>Environments</strong> (e.g., Dev, Production) and <strong>Configuration Profiles</strong> (defining feature flags or free-form JSON sourced from S3, Secrets Manager, or the AppConfig-hosted store).</p><p>Deployments are governed by a <strong>Deployment Strategy</strong>, which controls the rollout pace and bake time. Integrated <strong>Monitors</strong> watch Amazon CloudWatch alarms, triggering automatic rollbacks if issues arise. To eliminate runtime latency, the <strong>AWS AppConfig Agent</strong> runs alongside your application, asynchronously fetching and caching configuration data locally.</p><h4>Feature Flags vs Freeform Configuration</h4><p>There are two configuration options to choose from when using AppConfig:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Mm5pcBze1gB1Tm3LGKxdqw.png" /><figcaption>Feature flag</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3SQLtq5ZDNFqT6Os8UJsAg.png" /><figcaption>Freeform configuration</figcaption></figure><p>We would go with the Freeform configuration approach and use the AWS AppConfig hosted configuration store, so we don&#39;t need to create multiple feature flags and instead have a single file/config that contains the features we want to track.</p><h3>AWS CDK</h3><p>The <a href="https://docs.aws.amazon.com/cdk/api/v2/docs/aws-construct-library.html">AWS Cloud Development Kit</a> (AWS CDK) is an open-source software development framework for defining cloud resources as code.</p><p>Follow the prompt to complete the setup process.</p><p>1. <strong>Install AWS CDK CLI</strong>:</p><pre>npm install -g aws-cdk</pre><p>2. <strong>Set up environment</strong>:</p><pre>cd infrastructure<br>python3 -m venv .venv<br>source .venv/bin/activate<br>pip install -r requirements.txt</pre><p>3. <strong>Deploy infrastructure</strong>:</p><pre>cdk bootstrap # First time only<br><br># Deploy stack<br>cdk deploy AppStack --require-approval never</pre><h3>Project Overview</h3><p><strong>Stack</strong>:</p><pre>cdk-app-config/<br>├── app.py                  # CDK Main App Entrypoint<br>├── app_stack.py            # Main Stack<br>├── cdk_constructs/<br>│   ├── __init__.py<br>│   ├── auth_construct.py<br>│   └── appconfig_construct.py<br>├── lambda/<br>│   └── index.py            # Standalone Lambda source folder<br>└── config/<br>    └── config.json   # AppConfig JSON configuration file</pre><p>The folder structure is designed as follows:</p><ol><li>The app.py acts as the entry point.</li><li>The app_stack.py is the main stack; it utilizes constructs to create and manage resources.</li><li>The cdk_constructs folder contains the constructs, both <strong><em>auth_construct </em></strong>(which contains the API Gateway and Lambda) and the <strong><em>appconfig_construct </em></strong>(which contains the App Config resources), which the application needs.</li><li>The config/config.py is the config file that would be uploaded as an AppConfig FreeForm configuration.</li><li>The lambda/index.py is the source code of the Lambda function; it contains the business logic and interaction with AWS AppConfig.</li></ol><p>We will review some parts of the codebase, not all, however.</p><h3>App Config Construct</h3><pre>import os<br>from aws_cdk import (<br>    aws_appconfig as appconfig<br>)<br>from constructs import Construct<br><br>class AppConfigConstruct(Construct):<br><br>    def __init__(self, scope: Construct, construct_id: str, **kwargs) -&gt; None:<br>        super().__init__(scope, construct_id, **kwargs)<br><br>        self.application = appconfig.CfnApplication(<br>            self, &quot;AiGatewayApp&quot;,<br>            name=&quot;AiGateway&quot;<br>        )<br><br>        self.environment = appconfig.CfnEnvironment(<br>            self, &quot;ProdEnvironment&quot;,<br>            application_id=self.application.ref,<br>            name=&quot;Prod&quot;<br>        )<br><br>        self.configuration_profile = appconfig.CfnConfigurationProfile(<br>            self, &quot;ModelRouterProfile&quot;,<br>            application_id=self.application.ref,<br>            name=&quot;ModelRouter&quot;,<br>            location_uri=&quot;hosted&quot;<br>        )<br><br>        base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))<br>        config_path = os.path.join(base_dir, &quot;config&quot;, &quot;config.json&quot;)<br>        <br>        with open(config_path, &quot;r&quot;, encoding=&quot;utf-8&quot;) as file:<br>            config_content = file.read()<br><br>        hosted_version = appconfig.CfnHostedConfigurationVersion(<br>            self, &quot;InitialConfigVersion&quot;,<br>            application_id=self.application.ref,<br>            configuration_profile_id=self.configuration_profile.ref,<br>            content_type=&quot;application/json&quot;,<br>            content=config_content<br>        )<br><br>        appconfig.CfnDeployment(<br>            self, &quot;InitialDeployment&quot;,<br>            application_id=self.application.ref,<br>            environment_id=self.environment.ref,<br>            configuration_profile_id=self.configuration_profile.ref,<br>            configuration_version=hosted_version.ref,<br>            deployment_strategy_id=&quot;AppConfig.AllAtOnce&quot;  # Immediate rollout for testing<br>        )</pre><p>From the above, we set up the App Config application (<strong><em>AiGateway</em></strong>) and an environment (<strong><em>Prod</em></strong>), and also the configuration profile (<strong><em>ModelRouter</em></strong>) as a Freeform type, which uses the AppConfig hosted configuration store as its store.</p><p>Next, we upload the content of config/config.json and lastly create a deployment with a deployment strategy of <strong><em>AppConfig.AllAtOnce</em></strong>.</p><h3>Auth Construct</h3><pre>import os<br>from aws_cdk import (<br>    Duration,<br>    CfnOutput,<br>    Stack,<br>    aws_lambda as _lambda,<br>    aws_apigateway as apigateway,<br>    aws_iam as iam,<br>    aws_appconfig as appconfig<br>)<br>from constructs import Construct<br><br>class AuthConstruct(Construct):<br><br>    def __init__(self, scope: Construct, construct_id: str, **kwargs) -&gt; None:<br>        super().__init__(scope, construct_id, **kwargs)<br><br>        current_region = Stack.of(self).region<br><br>        app_config_layer_arn = appconfig.Application.get_lambda_layer_version_arn(<br>            region=current_region,<br>            platform=appconfig.Platform.X86_64<br>        )<br><br>        app_config_extension_layer = _lambda.LayerVersion.from_layer_version_arn(<br>            self, &quot;AppConfigExtension&quot;,<br>            layer_version_arn=app_config_layer_arn<br>        )<br><br>        base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))<br>        lambda_path = os.path.join(base_dir, &quot;lambda&quot;)<br><br>        gateway_lambda = _lambda.Function(<br>            self, &quot;AiGatewayFunction&quot;,<br>            runtime=_lambda.Runtime.PYTHON_3_11,<br>            handler=&quot;index.lambda_handler&quot;,<br>            code=_lambda.Code.from_asset(lambda_path),<br>            layers=[app_config_extension_layer],<br>            memory_size=256,<br>            timeout=Duration.seconds(10)<br>        )<br><br>        gateway_lambda.add_to_role_policy(<br>            iam.PolicyStatement(<br>                actions=[<br>                    &quot;appconfig:GetLatestConfiguration&quot;,<br>                    &quot;appconfig:StartConfigurationSession&quot;<br>                ],<br>                resources=[&quot;*&quot;]<br>            )<br>        )<br><br>        api = apigateway.RestApi(<br>            self, &quot;AiRouterApi&quot;,<br>            rest_api_name=&quot;Smart AI Gateway Service&quot;,<br>            description=&quot;Dynamically manages LLM endpoints utilizing AppConfig presets.&quot;,<br>            default_cors_preflight_options=apigateway.CorsOptions(<br>                allow_origins=apigateway.Cors.ALL_ORIGINS,<br>                allow_methods=apigateway.Cors.ALL_METHODS<br>            )<br>        )<br><br>        prompt_resource = api.root.add_resource(&quot;prompt&quot;)<br>        prompt_resource.add_method(<br>            &quot;POST&quot;, <br>            apigateway.LambdaIntegration(gateway_lambda),<br>            api_key_required=True<br>        )<br><br>        key = api.add_api_key(<br>            &quot;DevApiKey&quot;,<br>            api_key_name=&quot;ai-gateway-dev-key&quot;<br>        )<br>        plan = api.add_usage_plan(<br>            &quot;UsagePlan&quot;,<br>            name=&quot;AiGatewayBasicPlan&quot;<br>        )<br>        plan.add_api_stage(stage=api.deployment_stage)<br>        plan.add_api_key(key)<br><br>        CfnOutput(self, &quot;ApiUrl&quot;, value=f&quot;{api.url}prompt&quot;)</pre><p>This sets up the Lambda with its source code and layer(uses the AWS AppConfig Agent as its layer). The Lambda layer sets up the AWS agent, which acts as a sidecar and caches the app config data that the Lambda would need.</p><p>Since this runs as a sidecar alongside the runtime code, it can then be accessed locally from within the function’s execution environment by querying it via <a href="http://localhost:2772/...">http://localhost:2772/...</a></p><p>Since the Agent acts on behalf of the function, the function’s IAM execution role <em>must</em> be explicitly granted permissions for appconfig:StartConfigurationSession and appconfig:GetLatestConfiguration.</p><p>Finally, it sets up the prompt route on API Gateway with an API Key that points to the Lambda function and outputs the endpoint.</p><h3>Config.json</h3><pre>{<br>    &quot;active_llm_model&quot;: &quot;gpt-4o&quot;,<br>    &quot;enable_fallback_to_cheap_model&quot;: true,<br>    &quot;max_input_tokens_allowed&quot;: 20,<br>    &quot;system_prompt_version&quot;: &quot;v2.1&quot;<br>}</pre><p>This contains the JSON payload that the Lambda function would be expecting. During testing, we would update this code and verify that the lambda function reacts differently and responds accordingly.</p><h3>Lambda Function</h3><pre>import json<br>import urllib.request<br><br>def lambda_handler(event, context):<br>    url = &quot;http://localhost:2772/applications/AiGateway/environments/Prod/configurations/ModelRouter&quot;<br>    <br>    try:<br>        response = urllib.request.urlopen(url)<br>        config = json.loads(response.read().decode(&#39;utf-8&#39;))<br>    except Exception as e:<br>        return {&quot;statusCode&quot;: 500, &quot;body&quot;: json.dumps({&quot;error&quot;: f&quot;Failed to fetch AppConfig: {str(e)}&quot;})}<br><br>    body = json.loads(event.get(&quot;body&quot;, &quot;{}&quot;)) if event.get(&quot;body&quot;) else {}<br>    user_prompt = body.get(&quot;prompt&quot;, &quot;&quot;)<br><br>    max_tokens = config.get(&quot;max_input_tokens_allowed&quot;, 1000)<br>    if len(user_prompt.split()) &gt; max_tokens:<br>        return {<br>            &quot;statusCode&quot;: 400, <br>            &quot;body&quot;: json.dumps({&quot;error&quot;: f&quot;Prompt exceeds safe limits of {max_tokens} words.&quot;})<br>        }<br><br>    selected_model = config.get(&quot;active_llm_model&quot;, &quot;gpt-4o-mini&quot;)<br>    prompt_version = config.get(&quot;system_prompt_version&quot;, &quot;v1.0&quot;)<br><br>    return {<br>        &quot;statusCode&quot;: 200,<br>        &quot;headers&quot;: { &quot;Content-Type&quot;: &quot;application/json&quot; },<br>        &quot;body&quot;: json.dumps({<br>            &quot;status&quot;: &quot;success&quot;,<br>            &quot;model_used&quot;: selected_model,<br>            &quot;prompt_version&quot;: prompt_version,<br>            &quot;ai_response&quot;: f&quot;[Mocked LLM Response] Processed using model: {selected_model}&quot;<br>        })<br>    }</pre><p>From the above, we pull the current config via the local sidecar running and decide whether to allow the user-provided prompt, based on the token limit in the latest config or not.</p><h3>Testing the App</h3><p>After the stack has been successfully deployed, we now have to verify if the proposed solution is working as expected.</p><p>Get the output URL from the terminal after deploy, it should look something like below:</p><pre>https://unique-id.execute-api.us-east-1.amazonaws.com/prod/prompt</pre><p>Next, head over to AWS API Gateway to retrieve the API Key created, so as to attach it when making a POST request to the endpoint.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*q4Nrbq8NKSdGFn1gJrlY6g.png" /></figure><p>Next, we invoke the API. Remember to add the X-Api-Key header when making this request, with its value being the API Key you just retrieved.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pkZr2n-lbmGThBGiavfK-w.png" /></figure><p>We can see we have the mocked response, which means this worked as expected, since we currently have this in place in the config: “max_input_tokens_allowed”: 20 .</p><p>Next, we would update the prompt to go beyond the 20 token/words allowed:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*SRXTqDxkPY7labVb7upzRA.png" /><figcaption>400 response</figcaption></figure><p>Now we also see that we get a 400 response for a token count more than the allowed limit.</p><p>Assuming there is a new requirement to change the model and the token limit to a smaller limit. We would update the config.json and trigger a new deployment to test if we can get changes reflected as proposed in this article:</p><pre><br>{<br>  &quot;active_llm_model&quot;: &quot;gpt-4o-mini&quot;,<br>  &quot;enable_fallback_to_cheap_model&quot;: true,<br>  &quot;max_input_tokens_allowed&quot;: 10,<br>  &quot;system_prompt_version&quot;: &quot;v3.0-emergency-fallback&quot;<br>}</pre><p>To test, we run the deploy:</p><pre> cdk deploy AppStack --require-approval never</pre><p>Since this is an article, I just wanted to deploy to test, in prod, we would utilize CI/CD tools to perform this deployment, in that way we could have specific deployment for configs, so it doesnt get to deploy the full stack when a minor config change is done, to keep one of the wins of using AWS AppConfig in the first place, which is faster deployemnt.</p><p>One approach is to track file changes in the config folder and, rather than do a CDK deploy, run an AWS CLI AppConfig deployment command, so it simply updates and deploys. This way, you have a separate workflow for configs that&#39;s faster and independent.</p><p>Here is a sample below:</p><pre>aws appconfig create-hosted-configuration-version \<br>  --application-id &lt;app-id&gt; \<br>  --configuration-profile-id &lt;profile-id&gt; \<br>  --content fileb://config/config.json \<br>  --content-type application/json<br><br>aws appconfig start-deployment \<br>  --application-id &lt;app-id&gt; \<br>  --environment-id &lt;env-id&gt; \<br>  --configuration-profile-id &lt;profile-id&gt; \<br>  --configuration-version &lt;new-version&gt; \<br>  --deployment-strategy-id AppConfig.Linear50PercentEvery30Seconds</pre><p>That out of the way, head over to the AWS console.</p><p>We see the environment is updating and deploying:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dUpA-M7nb6jJzpTEi-SNBg.png" /><figcaption>Environments</figcaption></figure><p>Moving in/clicking:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HY5VrfIMIog2-6xIs6lPog.png" /><figcaption>Deployments</figcaption></figure><p>Also, looking at the ModelRouter, we see the latest version(2) now exists.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fKTlHsddbaPUtU9VXOC9uA.png" /><figcaption>Version History</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*stiSKWRSV91YsitDgq9G-Q.png" /><figcaption>Hosted Config</figcaption></figure><p>Now, head over to test with a prompt that has a word count of 12:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xpdXhinaXZmhcrUFGSCh2Q.png" /></figure><p>Now we see it fails for this token limit, just as we expected.</p><p>Lastly, we would update the prompt to be less than 10 words:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*l-NnJow8ubRNp64PvTRftA.png" /></figure><p>Perfect, it picked up the updated config changes without needing to perform any code changes on Lambda.</p><p>Great!</p><h3>Conclusion</h3><p>This has been an interesting ride; we learnt that to truly be agile, serverless systems require us to rethink where our configuration data lives.</p><p>We utilized AWS AppConfig to remove hardcoded configurations from within our source code and utilized a sidecar approach for faster updates and fetching of configurations, keeping our architecture rigid and highly scalable.</p><p>You can find the complete source code <a href="https://github.com/chyke007/cdk-app-config">here</a>.</p><p>Excited to see what you build.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=536fa46bc0ad" width="1" height="1" alt=""><hr><p><a href="https://towardsaws.com/beyond-boolean-toggles-data-rich-feature-flags-with-aws-appconfig-and-python-cdk-536fa46bc0ad">Beyond Boolean Toggles: Data-Rich Feature Flags with AWS AppConfig and Python CDK</a> was originally published in <a href="https://towardsaws.com/">Towards AWS</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Secure Serverless Document Management system with Amazon Verified Permission, CDK & Python]]></title>
            <link>https://towardsaws.com/secure-serverless-document-management-system-with-amazon-verified-permission-cdk-python-20b8d25f9594?source=rss-38b4b5e55906------2</link>
            <guid isPermaLink="false">https://medium.com/p/20b8d25f9594</guid>
            <category><![CDATA[aws-cdk]]></category>
            <category><![CDATA[rbac]]></category>
            <category><![CDATA[serverless]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[python]]></category>
            <dc:creator><![CDATA[Nwachukwu Chibuike]]></dc:creator>
            <pubDate>Wed, 18 Mar 2026 16:08:54 GMT</pubDate>
            <atom:updated>2026-06-11T13:23:07.727Z</atom:updated>
            <content:encoded><![CDATA[<p>Ever built a simple serverless API only to realize that authorization can get complicated quickly?</p><p>In this article, we will develop a production-ready serverless Document management system on AWS, using Cognito for authentication, Amazon Verified Permissions (Cedar) for fine-grained RBAC/ABAC, Lambda and API Gateway for APIs, and S3 and DynamoDB for storage and metadata sharing.</p><p>Already excited? Let’s get started.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*EoRoLBdn-VnxQiZcbzA51w.png" /><figcaption>Banner image</figcaption></figure><h3>What would we cover</h3><p>This article came from my need to deeply understand how to build a scalable authentication and authorization layer on AWS for a real serverless product.</p><p>It tackles the common pain points you hit as requirements grow, such as authorization logic scattered across Lambdas, endless if role == checks mixing <strong>RBAC</strong> and <strong>ABAC</strong> in inconsistent ways, and patterns that become hard to scale, hard to audit, and easy to get wrong.</p><p>We would get to achieve the following:</p><ul><li>Build an app using <strong>AWS CDK </strong>as IaC</li><li>Create Cedar Policies in Amazon Verified Permission (AVP)</li><li>Authentication via Amazon Cognito</li><li>API layer: Lambda + API Gateway endpoints and a Lambda authorizer that validates JWTs and forwards identity context.</li><li>Presigned URL workflow: upload/download with S3 presigned URLs, including pending → complete metadata updates (upload_complete flow).</li><li>Implement RBAC and ABAC via AVP, DynamoDB, and Cognito</li><li>Document Management using S3 and DynamoDB</li><li>UI: Streamlit app hosted on ECS Fargate (behind ALB) for the front-end/admin experience.</li><li>Observability &amp; Auditability: CloudWatch Logs + Logs Insights and AWS X-Ray Tracing</li></ul><h3>Prerequisites</h3><ul><li>AWS account</li><li>AWS CLI setup and configured with appropriate credentials</li><li><a href="https://cdkworkshop.com/15-prerequisites.html">AWS CDK</a> set up.</li><li><a href="https://www.python.org/downloads/">Python3</a></li><li><a href="https://www.docker.com/">Docker</a></li><li>Node.js and npm (for AWS CDK CLI)</li></ul><h3>Case Study: DocNext</h3><p>Building <strong>DocNext’s</strong> serverless document management platform started simple: a few Lambda handlers with inline checks like if role == “Admin”. But the access model quickly became really complicated in the real world. The team now needed to support document owners, departmental boundaries, cross-user sharing, and multiple roles (Admin, Manager, Editor, Viewer), while also maintaining an auditable trail of <em>who accessed what</em>, <em>when</em>, and <em>why it was allowed or denied</em>.</p><p>As the number of endpoints grew (upload, list, download, delete, share, audit), duplicating authorization logic across Lambdas became risky and hard to review. A small policy change required code edits, deployments, and regressions across multiple handlers. <strong>DocNext</strong> needed a way to separate authorization from business logic, keep authorization rules version-controlled, and make changes easy to review via pull requests.</p><p>You, as an engineer in the team, proposed the following:</p><p>Using Amazon Verified Permissions (AVP) with Cedar policies as the centralized authorization layer. Amazon Cognito provides identity and user attributes (role and department), and DynamoDB stores document attributes (owner, department, and shared_with). At runtime, the API uses a Lambda authorizer to validate Cognito JWTs and pass user context downstream, while each business Lambda calls AVP is_authorized using the user and document attributes to make a clear <strong>ALLOW/DENY</strong> decision.</p><p>To keep the system operable, observability would be built early on: Lambdas emit structured JSON logs to CloudWatch Logs, audit and error investigations run through CloudWatch Logs Insights, and AWS X-Ray traces capture performance and dependency behavior across calls to Cognito, AVP, DynamoDB, and S3.</p><p>You envision this will produce a system where authorization is consistent, auditable, and easy to evolve, without scattering security rules throughout the codebase. This gets approved by Management, now it&#39;s time to build and bring it to life!</p><h3>Proposed Architecture</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tl3mGlW-wdRfNuA6xRWk4g.png" /><figcaption>Main Architecture</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/694/0*Iez2cMUI7xTafwFn.png" /><figcaption>Main Architecture</figcaption></figure><h4>Document Upload using Pre-Signed URL approach</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6Vxgnx3-pniAOlgGe9dTLw.png" /><figcaption>Upload Document</figcaption></figure><p>The client(Streamlit/User) logs in and selects an image from their system, which sends a request to an API requesting a Presigned URL. Once the user is verified, the document details are stored in DynamoDB with a pending status, and the URL is sent back to the client.</p><p>Next, the user clicks the upload button after filling in other necessary information like the department the document belongs to, tags &amp; description (optional). This uploads the document securely to S3.</p><p>An EventBridge rule is attached to the bucket that gets triggered when an object is created. What this means is that after the document is added to S3, this event is triggered, and in turn, it calls a lambda function that updates the DynamoDB table with a status of complete and other metadata</p><h4>API Security (Authentication and Authorization)</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*QR0pLPK0o_e_fNfzryRBmw.png" /><figcaption>Authentication and Authorization flow</figcaption></figure><p>Clients authenticate with Amazon Cognito and receive a JWT. That token is sent on every API request as Authorization: Bearer &lt;token&gt;.</p><p>API Gateway uses a Lambda Authorizer to validate the JWT (Cognito JWKS), then attaches identity context (e.g., userId/sub, role, department, email) to the request and returns an IAM policy to allow or deny the call. If allowed, API Gateway forwards the request to the target Lambda with event.requestContext.authorizer populated.</p><p>Inside the target Lambda, the handler calls AVP is_authorized with the action (read/write/delete/share) and resource attributes (document owner, department, shared_with). Cedar policies evaluate this data and return ALLOW/DENY. Only when authorization is ALLOW does the Lambda proceed; otherwise, it returns an authorization failure.</p><p>The Lambda then returns the response through API Gateway to the client.</p><h4>Observability in Action</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*7Yxq09v_wu-Nc7t1t62sKQ.png" /></figure><p>All Lambdas write structured JSON logs using a shared logger, so CloudWatch logs stay consistent and easy to query (event type, request id, user/document id, role, outcome). CloudWatch Logs Insights powers the Audit page and makes it straightforward to trace “who did what, when”.</p><p>For tracing, AWS X-Ray captures end-to-end request traces, and AWS SDK calls (Cognito, AVP, DynamoDB, S3) to spot latency and failures quickly. A CloudWatch Dashboard pulls the key signals together (errors, latency, throttles, uploads).</p><h3>Amazon Verified Permission</h3><p>So what exactly is Amazon Verified Permission?</p><p><a href="https://docs.aws.amazon.com/verifiedpermissions/latest/userguide/what-is-avp.html">AVP</a> (Amazon Verified Permissions) is a scalable service for fine-grained authorization. It helps teams externalize access rules into centrally managed policies, so applications can enforce security consistently while moving faster.</p><p>It uses Cedar Permission, which is an open-source language for writing authorization policies and making authorization decisions based on those policies.</p><p>This <a href="https://www.youtube.com/watch?v=1AdkZJSmTQI">video</a> at AWS re:Inforce 2023 explains this well.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*SUr1Nmx_ixL-G3EQ.png" /><figcaption>Source: <a href="https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-authorization-with-avp.html">https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-authorization-with-avp.html</a></figcaption></figure><h4>RBAC and ABAC</h4><p>We implement <strong>RBAC </strong>(Role-Based Access Control) by assigning each user a role in a Cognito custom attribute and enforcing those roles centrally in Cedar policies via Amazon Verified Permissions.</p><p>In the Cedar schema, that becomes principal.role, and policies check principal.role == “Admin”, “Manager”, “Editor”, “Viewer”</p><p>We implement ABAC (Attribute-Based Access Control) by using user and resource attributes captured in Amazon Cognito (e.g., principal.department, principal.userId) and document attributes stored as the authorization resource in DynamoDB (e.g., resource.department, resource.owner, resource.sharedWith).</p><p>In the Cedar schema, these attributes appear as:</p><ul><li>principal.department (user attribute from Cognito)</li><li>principal.userId (Cognito sub as the user identifier)</li><li>resource.department, resource.owner, resource.sharedWith (document attributes)</li></ul><p>Policies then enforce ABAC rules, such as:</p><ul><li>Managers can access documents only when resource.department == principal.department</li><li>Owners can access their own documents when resource.owner == principal.userId</li><li>Shared document access is evaluated using resource.sharedWith.contains(principal.userId)</li></ul><p>Currently, we have 5 Cedar Policies. Let&#39;s bring them down and explain:</p><ol><li><strong>Admin Full Access</strong></li></ol><pre>// Cedar Policy: Admin Full Access<br>// Admin users have full access to all documents for all operations<br><br>permit(<br>    principal,<br>    action in [<br>        DocumentManagement::Action::&quot;read&quot;,<br>        DocumentManagement::Action::&quot;write&quot;,<br>        DocumentManagement::Action::&quot;delete&quot;,<br>        DocumentManagement::Action::&quot;share&quot;<br>    ],<br>    resource<br>)<br>when {<br>    principal has role &amp;&amp;<br>    principal.role == &quot;Admin&quot;<br>};</pre><p>This grants the admin full access to all documents</p><p>2. <strong>Document Owner Access</strong></p><pre>// Cedar Policy: Document Owner Permissions<br>// Document owners can always manage their own documents regardless of role<br><br>permit(<br>    principal,<br>    action in [<br>        DocumentManagement::Action::&quot;read&quot;,<br>        DocumentManagement::Action::&quot;write&quot;,<br>        DocumentManagement::Action::&quot;delete&quot;,<br>        DocumentManagement::Action::&quot;share&quot;<br>    ],<br>    resource<br>)<br>when {<br>    principal has userId &amp;&amp;<br>    resource has owner &amp;&amp;<br>    resource.owner == principal.userId<br>};</pre><p>This grants the document owner( this means the user who uploaded the document on the platform) full access to the document. This is irrespective of whatever role they have in the platform, since they “<strong><em>own</em></strong>” this document.</p><p>3. <strong>Editor Access</strong></p><pre>// Cedar Policy: Editor Owned/Shared Document Access<br>// Editor users can read and write documents they own or have been shared with<br><br>permit(<br>    principal,<br>    action in [<br>        DocumentManagement::Action::&quot;read&quot;,<br>        DocumentManagement::Action::&quot;write&quot;<br>    ],<br>    resource<br>)<br>when {<br>    principal has role &amp;&amp;<br>    principal.role == &quot;Editor&quot; &amp;&amp;<br>    principal has userId &amp;&amp;<br>    resource has owner &amp;&amp;<br>    (<br>        resource.owner == principal.userId ||<br>        (resource has sharedWith &amp;&amp; resource.sharedWith.contains(principal.userId))<br>    )<br>};</pre><p>This grants the Editor role read(<strong><em>list</em></strong> and <strong><em>download</em></strong> functionality via the web app) to resources, and also documents shared with them by other users.</p><p>Also, remember that if this Editor user uploads a document, they would also have full access, like <strong><em>delete</em></strong>, <strong><em>share</em></strong>, not included in this particular cedar. This is because when evaluating, AVP would also look at that Cedar policy and approve those actions. So in theory, the two Cedar policy files(editor and owner) would be used.</p><p>4. <strong>Departmental Manager Access</strong></p><pre>// Cedar Policy: Manager Departmental Access<br>// Manager users can manage documents in their department<br><br>permit(<br>    principal,<br>    action in [<br>        DocumentManagement::Action::&quot;read&quot;,<br>        DocumentManagement::Action::&quot;write&quot;,<br>        DocumentManagement::Action::&quot;delete&quot;,<br>        DocumentManagement::Action::&quot;share&quot;<br>    ],<br>    resource<br>)<br>when {<br>    principal has role &amp;&amp;<br>    principal.role == &quot;Manager&quot; &amp;&amp;<br>    principal has department &amp;&amp;<br>    resource has department &amp;&amp;<br>    principal.department == resource.department<br>};</pre><p>This grants the Manager role full access to documents created in their department.</p><p>So if we had 2 departments, <strong><em>Hr</em></strong> and <strong><em>IT</em></strong>. The Hr manager would only see documents uploaded for the Hr department and not those of the IT department.</p><p>5. <strong>Viewer Read-only Access</strong></p><pre>// Cedar Policy: Viewer Read-Only Access<br>// Viewer users have read-only access to documents shared with them<br><br>permit(<br>    principal,<br>    action == DocumentManagement::Action::&quot;read&quot;,<br>    resource<br>)<br>when {<br>    principal has role &amp;&amp;<br>    principal.role == &quot;Viewer&quot; &amp;&amp;<br>    principal has userId &amp;&amp;<br>    resource has sharedWith &amp;&amp;<br>    resource.sharedWith.contains(principal.userId)<br>};</pre><p>This grants the viewer read-only access to documents shared with them.</p><h4>Amazon Cognito and Amazon Verified Permissions (AVP)</h4><p><a href="https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-authorization-with-avp.html">Amazon Cognito and Amazon Verified Permissions (AVP)</a> work together to provide a comprehensive <strong>authentication and fine-grained authorization solution</strong> for the application.</p><p><strong>Cognito</strong> handles the authentication while <strong>AVP</strong> handles authorization.</p><h3>AWS CDK</h3><p>The <a href="https://docs.aws.amazon.com/cdk/api/v2/docs/aws-construct-library.html">AWS Cloud Development Kit</a> (AWS CDK) is an open-source software development framework for defining cloud resources as code.</p><p>Follow the prompt to complete the setup process.</p><p>1. <strong>Install AWS CDK CLI</strong>:</p><pre>npm install -g aws-cdk</pre><p>2. <strong>Set up environment</strong>:</p><pre>cd infrastructure<br>python3 -m venv .venv<br>source .venv/bin/activate</pre><p>3. <strong>Deploy infrastructure</strong>:</p><p><em>N:B</em>: The compute stack uses Docker to build the necessary packages used by Lambda; you would need to have that up and running locally.</p><pre>cd infrastructure # Move into cdk directory<br><br>cdk bootstrap # First time only<br><br># Deploy all (except ECS)<br>cdk deploy DocumentManagement-Networking DocumentManagement-Storage DocumentManagement-Auth DocumentManagement-Authorization DocumentManagement-Compute DocumentManagement-Observability  --require-approval never<br><br># Build &amp; Push docker streamlit image to ECR<br>cd ../ &amp;&amp; chmod +x build_and_push_streamlit.sh &amp;&amp; ./build_and_push_streamlit.sh<br><br># Deploy ECS stack<br>cd infrastructure &amp;&amp; cdk deploy DocumentManagement-ECS --require-approval never</pre><p>4. <strong>Create Admin User</strong>:</p><p>Run the following command to create the first user (Admin)</p><pre>python3 create_admin_user.py admin@example.com SecurePassword123!</pre><p>5. <strong>Visit app</strong>:</p><p>Next, access the Streamlit application via the ALB URL. Copy the <strong>DNS name </strong>and visit the URL.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qS4T6gW0ao7_cZ61nNIxRA.png" /><figcaption>ALB</figcaption></figure><p>The app should be up and show this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HdfI241HQQj6Q9LkMk8fKw.png" /><figcaption>App visited from Browser</figcaption></figure><p>Now the app is set up and ready for Testing.</p><h3>Project Overview</h3><p><strong>Stack</strong>:</p><pre><br>infrastructure/stacks/<br>├── networking_stack.py           # VPC, subnets, NAT gateway<br>├── storage_stack.py              # S3, DynamoDB tables<br>├── auth_stack.py                 # Cognito User Pool<br>├── authorization_stack.py        # Verified Permissions<br>├── compute_stack.py              # Lambda, API Gateway<br>├── observability_stack.py        # CloudWatch, X-Ray<br>└── ecs_stack.py                  # ECS</pre><p><strong>Streamlit app:</strong></p><pre><br>app/<br>├── app.py                 # Main application entry point<br>├── config.py              # Configuration management<br>├── auth.py                # Cognito authentication<br>├── api_client.py          # API Gateway client<br>├── session_manager.py     # Optional DynamoDB-backed Streamlit session<br>├── pages/                 # Application pages<br>│   ├── documents.py       # Document list page<br>│   ├── upload.py          # Document upload page<br>│   ├── share.py           # Document sharing page<br>│   ├── audit.py           # Audit logs page<br>│   └── admin.py           # Admin panel page<br>├── requirements.txt       # Python dependencies<br>├── Dockerfile             # Docker configuration<br>└── README.md              # This file</pre><h3>Testing</h3><p>Time to see the app in action.</p><h4>User Creation</h4><p>The Admin user creates 3 users in the same department <strong>Hr</strong>.</p><p>Two users are viewers, while one is a Manager of the <strong>Hr</strong> department:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cfEDjpHc2OYIQ_wlKk2IOw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BiuQEZNZfNvtqve5GOKSew.png" /></figure><h4>Document Creation</h4><p>When we log in as <strong><em>User 1</em></strong> and upload a document, we can see it in the documents section:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*toEevvi-twsBKgJ3iHkLrg.png" /><figcaption>My Documents</figcaption></figure><p>As the owner of this document, the user can perform all actions like read, share, delete, and download on this document.</p><p>Next, we test the sharing functionality.</p><h4>Sharing Documents</h4><p>The user shares this document with <strong><em>User 2</em></strong>:</p><p><strong>How sharing works</strong></p><p>Sharing does not call AVP directly. Instead, the Share Lambda updates the document’s shared_with attribute in DynamoDB.</p><p>When another user later requests access, the receiving Lambda calls AVP is_authorized. Cedar policies read shared_with from the document resource attributes to decide <strong><em>ALLOW</em></strong> or <strong><em>DENY</em></strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZOnvmm2asLq3mKldHF3I0A.png" /></figure><p>Now, when <strong><em>User 2 </em></strong>logs in, they should be able to see this document, even when they didn’t create it:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Y3Q7ZZqpYkhd4MMg1LasFw.png" /><figcaption>User 2 viewing User 1 documents</figcaption></figure><p>As can be seen, <strong><em>User 2</em></strong> can view the document <strong><em>User 1</em></strong> shared with them. They can download, but can&#39;t delete, since Amazon Verified Permission only allows the read action.</p><h4>Manager Oversight</h4><p><strong><em>User 3</em></strong> is the manager of the Hr department, and according to the Cedar policies we created, we should be able to see all documents created in the department.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8HHmwJCmwyS-vgExj1R3tw.png" /><figcaption>Manager viewing documents in their department</figcaption></figure><p>This document wasn&#39;t shared with the manager, nor does it belong to the manager(<strong><em>User 3</em></strong>). But the role of Manager allows the user to view all documents in their department.</p><p>They can also perform the delete and share action on the document, as their role permits these actions.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*FODDRYcxM7bTkEce1d6TSw.png" /><figcaption>Manager viewing shared docuemnts</figcaption></figure><h4>Audit Logs</h4><p>The audit logs show a structured log entry:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*r6OuU_VyqxHVp4fRF7swPA.png" /><figcaption>Audit logs from Manager view</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wPSJ_nUVO5ZTwEuf0Kzaaw.png" /><figcaption>More Audit logs</figcaption></figure><h4>Admin Oversight</h4><p>The Admin can view all documents and perform all actions on the document:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Glom3QE7_PsAM6rzXVHkLQ.png" /><figcaption>Admin viewing documents in the system</figcaption></figure><p>and also share and view all shared documents:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*NDgGhvw4R2qtqWqjyD2DSw.png" /><figcaption>Manager viewing all shared documents in the system</figcaption></figure><h4>Observability</h4><p><strong>Traces</strong>: Looking at some request traces.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*r2vDMGofsrb6YF1X7Trxdw.png" /></figure><p>We start with the list document flow:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nRWvHbMWJ8HTVyr2y3Qpnw.png" /></figure><p>Viewing the first trace, we see:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*y8yn3X9UGR81LIqG4tD3ow.png" /><figcaption>Trace</figcaption></figure><p>Scrolling down, we see the breakdown of the traces shown in timeline format:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4ieY7THrPCfwixLPdBiuGQ.png" /></figure><p><strong>Dashboard</strong>: Viewing the CloudWatch Dashboard:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WoZ7jXEgmgK-Z76UYj5fyg.png" /><figcaption>Dashboard</figcaption></figure><p>Looking at the Dashboard, we see more information about the app:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*QvI7BBnhJ5AJRKbseLNCVg.png" /><figcaption>Document Dashboard</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jVOlyA_fZiMfCPHYuFt9iw.png" /><figcaption>Cleanup Lambda invocation</figcaption></figure><h3>Lessons learnt</h3><ol><li><strong>Separating Authorization</strong>: Using AVP and Cedar policy from business logic makes scalability easier; we can simply update or add a new policy document without touching business logic.</li><li><strong>Cost Implications</strong>: Billed per authorization evaluation. In our implementation, this means roughly one AVP call per document we return in each API. This means if we list 50 documents and call AVP per document, that’s 50 AVP evaluations for one API call. This is correct from a security standpoint, but something to take note of in terms of cost.</li><li><strong>RBAC alone isn’t enough</strong>: Real systems need ABAC (department, owner, shared_with) to express scope cleanly.</li><li><strong>Observability matters</strong>: Structured “authorization decision” logs + X-Ray traces reduce time-to-debug when policies or attributes don’t match expectations. Use logging and Insights early to validate decisions (who got <strong>DENY</strong> and why).</li></ol><h3>Conclusion</h3><p>This has been an interesting ride. We got to explore the Authentication and Authorization part of AWS, implemented various best practices, and designed a production-ready serverless document management system powered by Amazon Verified Permission.</p><p>You can find the complete source code <a href="https://github.com/chyke007/serverless-document-avp">here</a>.</p><p>Excited to see what you build!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=20b8d25f9594" width="1" height="1" alt=""><hr><p><a href="https://towardsaws.com/secure-serverless-document-management-system-with-amazon-verified-permission-cdk-python-20b8d25f9594">Secure Serverless Document Management system with Amazon Verified Permission, CDK &amp; Python</a> was originally published in <a href="https://towardsaws.com/">Towards AWS</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Autonomous AI Agents: Web Browsing and Code Execution with Strands on Amazon Bedrock AgentCore]]></title>
            <link>https://towardsaws.com/autonomous-ai-agents-web-browsing-and-code-execution-with-strands-on-amazon-bedrock-agentcore-784f6983ef79?source=rss-38b4b5e55906------2</link>
            <guid isPermaLink="false">https://medium.com/p/784f6983ef79</guid>
            <category><![CDATA[amazon-bedrock-agentcore]]></category>
            <category><![CDATA[web-automation]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[python]]></category>
            <dc:creator><![CDATA[Nwachukwu Chibuike]]></dc:creator>
            <pubDate>Wed, 14 Jan 2026 17:01:51 GMT</pubDate>
            <atom:updated>2026-01-16T08:32:51.398Z</atom:updated>
            <content:encoded><![CDATA[<p>In the last <a href="https://nwachukwuchibuike.medium.com/building-persistent-memory-agents-with-langgraph-and-amazon-bedrock-agentcore-b0cb2876c65b">article</a>, we were introduced to Amazon AgentCore. We explored AgentCore Runtime and Memory in detail for building persistent memory AI Agents.</p><p>In this article, we will explore how to create AI agents that use built-in tools in Amazon Bedrock AgentCore, Code Interpreter, and Browser.</p><p>Let&#39;s get started.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lQd-n0pMuu4nGMZT0vQUsw.png" /></figure><h3>Prerequisites</h3><ul><li>AWS account</li><li>AWS CLI setup locally</li><li><a href="https://www.python.org/downloads/">Python 3</a></li><li><a href="https://nwachukwuchibuike.medium.com/building-persistent-memory-agents-with-langgraph-and-amazon-bedrock-agentcore-b0cb2876c65b">Previous article</a></li></ul><h3>What would we cover</h3><ul><li>We cover what the Amazon Bedrock AgentCore Browser is and its role in the application.</li><li>We cover what the Amazon Bedrock Code Interpreter is and its role in the application.</li><li>We walk through the Python code.</li><li>We perform some tests to see it in action.</li></ul><h3>Getting started</h3><p>To get started, run the command below. This sets up the project and installs the required packages.</p><pre>mkdir strands-browser &amp;&amp; cd strands-browser<br>python3 -m venv .venv<br>source .venv/bin/activate<br>pip install bedrock-agentcore strands-agents strands-agents-tools playwright nest-asyncio</pre><h3>AgentCore Browser</h3><p>AgentCore <a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/browser-tool.html">Browser</a> tool enables AI agents interact with web pages in a secure and managed environment, particularly useful for integrating search into an AI agent.</p><p>It uses a self-managed Chrome browser (Chromium) for this browser automation.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*DYYmc_hRjgH9e3uj.png" /><figcaption>Source: <a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/browser-tool.html">https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/browser-tool.html</a></figcaption></figure><p>We would utilize this tool to enable browser automation within our Strands agent.</p><p>Before going forward, we need to add the required permissions needed to work with the AgentCore Browser.</p><p>Attach this policy to your IAM user:</p><pre>{  <br>    &quot;Version&quot;:&quot;2012-10-17&quot;,                                    <br>    &quot;Statement&quot;: [  <br>        {  <br>            &quot;Sid&quot;: &quot;BedrockAgentCoreBrowserFullAccess&quot;,  <br>            &quot;Effect&quot;: &quot;Allow&quot;,  <br>            &quot;Action&quot;: [  <br>                &quot;bedrock-agentcore:CreateBrowser&quot;,  <br>                &quot;bedrock-agentcore:ListBrowsers&quot;,  <br>                &quot;bedrock-agentcore:GetBrowser&quot;,  <br>                &quot;bedrock-agentcore:DeleteBrowser&quot;,  <br>                &quot;bedrock-agentcore:StartBrowserSession&quot;,  <br>                &quot;bedrock-agentcore:ListBrowserSessions&quot;,  <br>                &quot;bedrock-agentcore:GetBrowserSession&quot;,  <br>                &quot;bedrock-agentcore:StopBrowserSession&quot;,  <br>                &quot;bedrock-agentcore:UpdateBrowserStream&quot;,  <br>                &quot;bedrock-agentcore:ConnectBrowserAutomationStream&quot;,  <br>                &quot;bedrock-agentcore:ConnectBrowserLiveViewStream&quot;  <br>            ],  <br>            &quot;Resource&quot;: &quot;arn:aws:bedrock-agentcore:&lt;Region&gt;:&lt;account_id&gt;:browser/*&quot;  <br>        },  <br>        {  <br>            &quot;Sid&quot;: &quot;BedrockModelAccess&quot;,  <br>            &quot;Effect&quot;: &quot;Allow&quot;,  <br>            &quot;Action&quot;: [  <br>                &quot;bedrock:InvokeModel&quot;,  <br>                &quot;bedrock:InvokeModelWithResponseStream&quot;  <br>            ],  <br>            &quot;Resource&quot;: [  <br>                &quot;*&quot;  <br>            ]  <br>        }  <br>    ]  <br>}</pre><p>Note: Replace &lt;Region&gt; with your chosen region and &lt;account_id&gt; with your account id</p><p>Next, create a new file called browser_agent.py in the root of the project (strands-browser/browser_agent.py):</p><pre>from strands import Agent<br>from strands_tools.browser import AgentCoreBrowser<br><br># Initialize the Browser tool<br>browser_identifier = &quot;browser_identifier&quot;<br>browser_tool = AgentCoreBrowser(region=&quot;us-east-1&quot;, identifier=browser_identifier)<br><br>agent = Agent(tools=[browser_tool.browser])<br><br>prompt = &quot;What can AgentCore Browser do, least 5 benefits it brings to an AI agent? Use this website link if needed: https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/browser-tool.html&quot;print(f&quot;\\n\\nPrompt: {prompt}\\n\\n&quot;)<br>response = agent(prompt)<br>print(&quot;\\n\\nAgent Response:&quot;)<br>print(response.message[&quot;content&quot;][0][&quot;text&quot;])</pre><p>Notice, we have a variable browser_identifier.</p><p>Head over to your AWS Console and into Amazon Bedrock AgentCore. Next, under the built-in tool, click on the Browser tool. Create a new Browser tool.</p><p>Create a new S3 bucket; we would need this, as recordings would be stored there. Select the bucket after creating.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Om-mAuarSU60u6omzUD24w.png" /></figure><p>For Permission, we would need a new IAM role with the needed permissions for S3 and CloudWatch. This would enable this Browser tool to save recordings and logs.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Zv7pJw8CUfk5XO2zx_FY8A.png" /></figure><p>Create an IAM policy with these permissions.</p><pre>{<br>    &quot;Version&quot;: &quot;2012-10-17&quot;,<br>    &quot;Statement&quot;: [<br>        {<br>            &quot;Sid&quot;: &quot;S3Permissions&quot;,<br>            &quot;Effect&quot;: &quot;Allow&quot;,<br>            &quot;Action&quot;: [<br>                &quot;s3:PutObject&quot;,<br>                &quot;s3:GetObject&quot;,<br>                &quot;s3:ListBucket&quot;,<br>                &quot;s3:ListMultipartUploadParts&quot;,<br>                &quot;s3:AbortMultipartUpload&quot;<br>            ],<br>            &quot;Resource&quot;: [<br>                &quot;arn:aws:s3:::s3-bucket&quot;,<br>                &quot;arn:aws:s3:::s3-bucket/*&quot;<br>            ]<br>        },<br>        {<br>            &quot;Sid&quot;: &quot;CloudWatchLogsPermissions&quot;,<br>            &quot;Effect&quot;: &quot;Allow&quot;,<br>            &quot;Action&quot;: [<br>                &quot;logs:CreateLogGroup&quot;,<br>                &quot;logs:CreateLogStream&quot;,<br>                &quot;logs:PutLogEvents&quot;,<br>                &quot;logs:DescribeLogStreams&quot;<br>            ],<br>            &quot;Resource&quot;: &quot;*&quot;<br>        }<br>    ]<br>}</pre><p>Attach this policy to an IAM role, and select this role in the Permissions section. Finally, click the <strong>Create</strong> button.</p><p>Copy the <strong>Browser tool ID,</strong> which is the actual value of browser_identifier we have in the code. Replace that variable content with the value you copied.</p><p>Now run the app.</p><p>python browser_agent.py</p><p>Checking the AWS Console, we see we have an active session:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8swat1z5I5gjZRYPlnBMWA.png" /><figcaption>Live session</figcaption></figure><p>The output in the terminal:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*636FKNwLVuT7quQ-PJqyTw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_EoAteETXM4_hQSROA5w4g.png" /></figure><h4>Observability</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vHSFqXy8u8FtBSmMLrvaWQ.png" /></figure><p>AgentCore also provides observability into what occurs when we use this tool. We can see how the agent interacted with the browser, view live sessions, recordings, and more.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*QBENBVFvobyduCGT6ktLVw.png" /></figure><p>We can also see the live session by clicking the <strong>View live session</strong> button</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yhIuR0iPPZObSMaEwrGvYw.png" /><figcaption>Live Session</figcaption></figure><p>Once we terminate the session, we can see the recording as well:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4n9zuDnzYZTCpgE5324wbQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*e4bgj671r3RQmqh8DjDw3Q.png" /></figure><h3>Code Interpreter</h3><p>The Amazon Bedrock AgentCore <a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/code-interpreter-tool.html">Code Interpreter</a> enables AI agents to write and execute code securely in sandbox environments, reducing hallucination and increasing their accuracy.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*3MfuQk8spOuA02qM.png" /><figcaption>Source: <a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/code-interpreter-tool.html">https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/code-interpreter-tool.html</a></figcaption></figure><p>Attach this policy to your IAM user:</p><pre><br>{<br>    &quot;Version&quot;:&quot;2012-10-17&quot;,<br>    &quot;Statement&quot;: [<br>        {<br>            &quot;Sid&quot;: &quot;BedrockAgentCoreCodeInterpreterFullAccess&quot;,<br>            &quot;Effect&quot;: &quot;Allow&quot;,<br>            &quot;Action&quot;: [<br>                &quot;bedrock-agentcore:CreateCodeInterpreter&quot;,<br>                &quot;bedrock-agentcore:StartCodeInterpreterSession&quot;,<br>                &quot;bedrock-agentcore:InvokeCodeInterpreter&quot;,<br>                &quot;bedrock-agentcore:StopCodeInterpreterSession&quot;,<br>                &quot;bedrock-agentcore:DeleteCodeInterpreter&quot;,<br>                &quot;bedrock-agentcore:ListCodeInterpreters&quot;,<br>                &quot;bedrock-agentcore:GetCodeInterpreter&quot;,<br>                &quot;bedrock-agentcore:GetCodeInterpreterSession&quot;,<br>                &quot;bedrock-agentcore:ListCodeInterpreterSessions&quot;<br>            ],<br>            &quot;Resource&quot;: &quot;arn:aws:bedrock-agentcore:&lt;region&gt;:&lt;account_id&gt;:code-interpreter/*&quot;<br>        }<br>    ]<br>}</pre><p>Note: Replace &lt;region&gt; with your chosen region and &lt;account_id&gt; with your account id</p><p>Next, create a new file called code_interpreter_agent.py in the root of the project (strands-browser/code_interpreter_agent.py):</p><pre>from strands import Agent<br>from strands_tools.code_interpreter import AgentCoreCodeInterpreter<br><br># Initialize the Code Interpreter tool<br>code_interpreter_tool = AgentCoreCodeInterpreter(region=&quot;us-east-1&quot;)<br><br>SYSTEM_PROMPT = &quot;&quot;&quot;You are an AI assistant that validates answers through code execution.<br>When asked about code, algorithms, or calculations, write Python code to verify your answers.&quot;&quot;&quot;<br><br>agent = Agent(<br>    tools=[code_interpreter_tool.code_interpreter],<br>    system_prompt=SYSTEM_PROMPT<br>)<br><br>prompt = &quot;What is 10 factorial, then add 12 to the result&quot;<br>print(f&quot;\n\nPrompt: {prompt}\n\n&quot;)<br><br>response = agent(prompt)<br>print(response.message[&quot;content&quot;][0][&quot;text&quot;])</pre><p>Now run the app.</p><p>python code_interpreter_agent.py</p><p>This output in the terminal:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*66hBTlCuhoCHaH7B6uT4Dg.png" /></figure><p>As can be seen, it uses the <strong>code_interpreter</strong> tool(Tool #1) and gives an accurate error, preventing Hallucination.</p><h3>Conclusion</h3><p>Building on the previous Bedrock AgentCore article, we explored the built-in tools in the article. We also experimented with <strong>Browser </strong>and<strong> Code Interpreter </strong>tools using the Strands Agent.</p><p>Go and experiment, and can&#39;t wait to see what you build!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=784f6983ef79" width="1" height="1" alt=""><hr><p><a href="https://towardsaws.com/autonomous-ai-agents-web-browsing-and-code-execution-with-strands-on-amazon-bedrock-agentcore-784f6983ef79">Autonomous AI Agents: Web Browsing and Code Execution with Strands on Amazon Bedrock AgentCore</a> was originally published in <a href="https://towardsaws.com/">Towards AWS</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building Persistent Memory Agents with LangGraph and Amazon Bedrock AgentCore]]></title>
            <link>https://towardsaws.com/building-persistent-memory-agents-with-langgraph-and-amazon-bedrock-agentcore-b0cb2876c65b?source=rss-38b4b5e55906------2</link>
            <guid isPermaLink="false">https://medium.com/p/b0cb2876c65b</guid>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[python]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[langgraph]]></category>
            <category><![CDATA[amazon-bedrock-agentcore]]></category>
            <dc:creator><![CDATA[Nwachukwu Chibuike]]></dc:creator>
            <pubDate>Fri, 19 Dec 2025 18:00:40 GMT</pubDate>
            <atom:updated>2025-12-24T02:57:01.540Z</atom:updated>
            <content:encoded><![CDATA[<p>AWS launched Amazon Bedrock AgentCore to address the challenge of deploying and operating production-grade agents securely and at scale, regardless of the framework or model used.</p><p>In this article, we combine LangGraph with Amazon Bedrock AgentCore to build and deploy AI agents with persistent memory capabilities. We explore how to create agents that maintain conversation context across sessions and server restarts, leveraging the powerful memory management features of Amazon Bedrock AgentCore alongside the flexible agent orchestration provided by the open-source LangGraph framework.</p><p>Let’s get right into it!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/0*Av5Uzs125GHYydh9.png" /><figcaption>Amazon Bedrock AgentCore</figcaption></figure><h3>Prerequisites</h3><ul><li>AWS account</li><li>AWS CLI setup locally</li><li><a href="https://cdkworkshop.com/15-prerequisites.html">AWS CDK</a> set up.</li><li><a href="https://www.python.org/downloads/">Python 3</a></li><li><a href="https://www.docker.com/">Docker</a></li></ul><h3>What would we cover</h3><ul><li>We cover what Amazon Bedrock AgentCore is</li><li>We cover what Langraph is and its role in the application</li><li>We cover the core services of Amazon Bedrock AgentCore</li><li>We walk through the Python code.</li><li>We perform some tests to see it in action.</li></ul><h3>What is Amazon Bedrock AgentCore</h3><p><a href="https://aws.amazon.com/bedrock/agentcore/">Amazon Bedrock Agentcore</a> is an agent-centric platform for building, deploying, and operating effective agents securely at scale, eliminating the need to manage any infrastructure.</p><p>It consists of the following core services broken down into 3 main stages: <em>Build</em>, <em>Deploy</em> &amp; <em>Operate</em>:</p><p>The <strong>Build</strong> stage:</p><ol><li><strong>AgentCore Gateway</strong>: Acts as an intermediary between your agent and tools, and could assist in turning lambda functions or api integrations into tools.</li><li><strong>AgentCore Memory</strong>: Allows the integration of both short-term and long-term memory into your AI application, that persist across sessions. We would see more of this later on in this article.</li><li><strong>AgentCore Policy</strong>: Provides the capability to define boundaries for your agents that suit business rules.</li><li><strong>AgentCore Identity: </strong>This brings a secure and scalable agent identity, access, and authentication management service.</li><li><strong>AgentCore CodeIntepreter</strong>(Inbuilt tool): Provides an isolated sandbox environment for agents to execute code.</li><li><strong>AgentCore Browser</strong>(Inbuilt tool): Provides a cloud-browser-based runtime environment that enables AI agents to interact with web applications, while in a fully managed environment.</li></ol><p>The <strong>Deploy</strong> stage:</p><ol><li><strong>AgentCore Runtime</strong>: This is a secure, serverless runtime environment purpose-built for deploying and scaling AI agents and tools, with benefits like fast cold starts, built-in identity, and more.</li></ol><p>The <strong>Operate</strong> stage:</p><ol><li><strong>AgentCore Observability: </strong>Provides a centralised and detailed monitor and debug agent performance in production.</li><li><strong>AgentCore Evaluations: </strong>Provides measurable quality signals that enable teams optimize performance with the structured insights provided.</li></ol><h3>What is LangGraph</h3><p><a href="https://www.langchain.com/langgraph">LangGraph</a> is the library that uses a graph-based approach to build and manage complex stateful AI agent workflows.</p><p>For the app, we would build a simple agent that takes users&#39; input and outputs a response.</p><h3>Getting started</h3><p>Following this <a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/agentcore-get-started-toolkit.html">guide</a>, we run the following command. This uses the AgentCore starter toolkit.</p><pre>pip install bedrock-agentcore-starter-toolkit<br>agentcore create <br></pre><p>Follow all the prompts to create a new app. For the article, I used/choose the following:</p><pre>Where should we create your new agent?<br>./pythonlanggraph     <br>How would you like to start?<br>A production-ready agent defined with Terraform or CDK<br>What agent framework should we use?<br>LangChain + LangGraph<br>Which model provider will power your agent?<br>Amazon Bedrock<br>Which IaC proivder will define your AgentCore resources?<br>CDK<br>Run agentcore configure first? (Further define configuration and reference exisiting re<br>Yes<br> <br>Configuring Bedrock AgentCore…<br>🏷️ Inferred agent name: create_agent<br>Press Enter to use this name, or type a different one (alphanumeric without &#39;-&#39;)<br>Agent name [create_agent]:<br>✓ Using agent name: create_agent<br>🚀 Deployment Configuration<br>Create mode only uses the container deployment type.<br>✓ Using: Container<br>🔐 Authorization Configuration<br>By default, Bedrock AgentCore uses IAM authorization.<br>Configure OAuth authorizer instead? (yes/no) [no]: no<br>✓ Using default IAM authorization<br>Configure request header allowlist? (yes/no) [no]: no<br>✓ Using default request header configuration</pre><p>N:B: At the time of writing this article, AWS CDK for Amazon Bedrock Agentcore is still experimental. More info <a href="https://docs.aws.amazon.com/cdk/api/v2/docs/aws-bedrock-agentcore-alpha-readme.html">here</a>.</p><h4>Testing Locally</h4><p>Replace the code src/main.py with:</p><pre>import os<br>from langchain_core.messages import HumanMessage<br>from langchain.agents import create_agent<br>from bedrock_agentcore import BedrockAgentCoreApp<br>from .mcp_client.client import get_streamable_http_mcp_client as deployed_get_tools<br>from model.load import load_model<br><br>if os.getenv(&quot;LOCAL_DEV&quot;) == &quot;1&quot;:<br>    # In local dev, instantiate dummy MCP client so the code runs without deploying<br>    async def get_tools():<br>        return []<br>else:<br>    get_tools = deployed_get_tools<br><br># Instantiate model<br>llm = load_model()<br><br><br># Import AgentCore Gateway as Streamable HTTP MCP Client<br><br># Integrate with Bedrock AgentCore<br>app = BedrockAgentCoreApp()<br><br>@app.entrypoint<br>async def invoke(payload):<br>    # assume payload input is structured as { &quot;prompt&quot;: &quot;&lt;user input&gt;&quot; }<br><br><br>    # Define the agent<br>    graph = create_agent(llm)<br><br>    # Process the user prompt<br>    prompt = payload.get(&quot;prompt&quot;, &quot;What is Agentic AI?&quot;)<br><br>    # Run the agent<br>    result = await graph.ainvoke({&quot;messages&quot;: [HumanMessage(content=prompt)]})<br><br>    # Return result<br>    return {<br>        &quot;result&quot;: result[&quot;messages&quot;][-1].content<br>    }<br><br>if __name__ == &quot;__main__&quot;:<br>    app.run()</pre><p>Next, we run the agent locally and test. Run the code below in the open terminal</p><pre>cd pythonlanggraph<br>agentcore dev</pre><p>In another terminal, run this to test. This ensures everything works well locally.</p><pre>agentcore invoke --dev &#39;{&quot;prompt&quot;: &quot;who are you?&quot;}&#39;</pre><p>You could also make a <strong><em>POST</em></strong> request to the localhost:8080/invocations endpoint with the payload body as {“prompt”: “Who are you?”}.</p><pre>CURL -X POST http://localhost:8080/invocations \<br>-H &quot;Content-Type&quot;: &quot;application/json&quot; \<br>-d &#39;{&quot;prompt&quot;: &quot;Who are you?&quot;}&#39;</pre><p>This should return this as the outcome in the terminal:</p><pre>✓ Response from dev server:<br>{<br>    &#39;response&#39;: &#39;{&quot;result&quot;: &quot;I\&#39;m Claude, an AI assistant made<br>by Anthropic. I\&#39;m here to help answer questions, have <br>conversations, assist with tasks, and provide information on a<br>wide variety of topics. In this particular context, I have <br>access to a simple math function that can add two numbers <br>together, though I can help with much more than just <br>arithmetic. \\n\\nIs there something specific I can help you <br>with today?&quot;}&#39;<br>}</pre><h3>Adding Memory</h3><p>Next, we update the code to create a LangGraph state graph with a node that calls an LLM. The code below uses an in-built memory.</p><pre>import os<br>from typing import TypedDict, List, Annotated<br>from langchain_core.messages import HumanMessage, AIMessage, BaseMessage<br>from langgraph.graph import StateGraph, END, add_messages<br>from langgraph.checkpoint.memory import MemorySaver<br>from bedrock_agentcore import BedrockAgentCoreApp<br>from .mcp_client.client import get_streamable_http_mcp_client as deployed_get_tools<br>from .model.load import load_model<br><br># Environment-based tool selection<br>if os.getenv(&quot;LOCAL_DEV&quot;) == &quot;1&quot;:<br>    # In local dev, instantiate dummy MCP client so the code runs without deploying<br>    async def get_tools():<br>        return []<br>else:<br>    get_tools = deployed_get_tools<br><br># Initialize components once at module level<br>llm = load_model()<br><br># Initialize memory saver for LangGraph<br>memory_saver = MemorySaver()<br><br># Integrate with Bedrock AgentCore<br>app = BedrockAgentCoreApp()<br><br># Define the state structure with proper message accumulation<br>class GraphState(TypedDict):<br>    messages: Annotated[List[BaseMessage], add_messages]<br><br># Define the LLM node<br>async def llm_node(state: GraphState) -&gt; GraphState:<br>    &quot;&quot;&quot;Node that processes messages through the LLM.&quot;&quot;&quot;<br>    messages = state[&quot;messages&quot;]<br>    <br>    # Process all messages (including conversation history) through the LLM<br>    response = await llm.ainvoke(messages)<br>    return {&quot;messages&quot;: [response]}<br><br># Create the StateGraph<br>workflow = StateGraph(GraphState)<br><br># Add nodes<br>workflow.add_node(&quot;llm&quot;, llm_node)<br><br># Set entry point<br>workflow.set_entry_point(&quot;llm&quot;)<br><br># Add edge from llm to END<br>workflow.add_edge(&quot;llm&quot;, END)<br><br># Compile the graph with memory<br>graph = workflow.compile(checkpointer=memory_saver)<br><br>@app.entrypoint<br>async def invoke(payload):<br>    &quot;&quot;&quot;Process user prompt through the LangGraph with memory and return response.&quot;&quot;&quot;<br>    # Extract prompt and session_id from payload<br>    prompt = payload.get(&quot;prompt&quot;, &quot;What is Agentic AI?&quot;)<br>    session_id = payload.get(&quot;session_id&quot;, &quot;default_session&quot;)<br>    <br>    # Create human message<br>    human_message = HumanMessage(content=prompt)<br>    <br>    # Configure with session for memory persistence<br>    config = {&quot;configurable&quot;: {&quot;thread_id&quot;: session_id}}<br>    <br>    # Invoke the graph with the new message<br>    result = await graph.ainvoke(<br>        {&quot;messages&quot;: [human_message]},<br>        config=config<br>    )<br>    <br>    # Get the last AI message<br>    last_message = result[&quot;messages&quot;][-1]<br>    <br>    # Return the response<br>    return {<br>        &quot;result&quot;: last_message.content,<br>        &quot;session_id&quot;: session_id<br>    }<br>if __name__ == &quot;__main__&quot;:<br>    app.run()</pre><p>To test run:</p><pre>agentcore invoke --dev &#39;{&quot;prompt&quot;: &quot;who are you?&quot;, &quot;session_id&quot;: &quot;10&quot;}&#39;</pre><p>Output:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*aP5Ec341x4ET0arCPUPG9Q.png" /></figure><p>Running again:</p><pre>agentcore invoke --dev &#39;{&quot;prompt&quot;: &quot;what did I ask last?&quot;, &quot;session_id&quot;: &quot;10&quot;}&#39;</pre><p>Output:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dbnJpdFougXnqUlpeZoJyg.png" /></figure><p>Running one more time:</p><pre>agentcore invoke --dev &#39;{&quot;prompt&quot;: &quot;what is the capital of Nigeria?&quot;, &quot;session_id&quot;: &quot;10&quot;}&#39;</pre><p>Output:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Cb4SSBRQbaUX2HhO3HP1JQ.png" /></figure><p>The problem with this is when we restart the server, the session id become invalid. Restart the server and invoke the agent one more time:</p><pre>agentcore invoke --dev &#39;{&quot;prompt&quot;: &quot;what did I ask last?&quot;, &quot;session_id&quot;: &quot;10&quot;}&#39;</pre><p>Output:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xWe4i3rUKo6sGw9MIJkWzg.png" /></figure><p>It doesn’t retain the conversation after we restart. We can fix this issue with Agentcore memory, as we would store the conversation in AgentCore, making it persistent even after server restarts.</p><h4>Integrating Amazon Bedrock AgentCore Memory</h4><p>Now, we integrate AgentCore <a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/memory-get-started.html">Memory</a> to see how it supports.</p><p>Update the codebase with the following:</p><pre>import os<br>import asyncio<br>import uuid<br>from typing import TypedDict, List, Annotated<br>from langchain_core.messages import HumanMessage, AIMessage, BaseMessage<br>from langgraph.graph import StateGraph, END, add_messages<br>from langgraph.checkpoint.memory import MemorySaver<br>from bedrock_agentcore import BedrockAgentCoreApp, BedrockAgentCoreContext<br>from bedrock_agentcore_starter_toolkit.operations.memory.manager import MemoryManager<br>from bedrock_agentcore.memory.session import MemorySessionManager<br>from bedrock_agentcore.memory.constants import ConversationalMessage, MessageRole<br>from bedrock_agentcore_starter_toolkit.operations.memory.models.strategies import SummaryStrategy<br>from .mcp_client.client import get_streamable_http_mcp_client as deployed_get_tools<br>from .model.load import load_model<br><br># Environment-based tool selection<br>if os.getenv(&quot;LOCAL_DEV&quot;) == &quot;1&quot;:<br>    # In local dev, instantiate dummy MCP client so the code runs without deploying<br>    async def get_tools():<br>        return []<br>else:<br>    get_tools = deployed_get_tools<br><br># Initialize components once at module level<br>llm = load_model()<br><br># Integrate with Bedrock AgentCore<br>app = BedrockAgentCoreApp()<br><br># Initialize Bedrock AgentCore Memory Manager<br>memory_manager = MemoryManager(region_name=os.getenv(&quot;AWS_REGION&quot;, &quot;us-east-1&quot;))<br><br># Single user constant<br>SINGLE_USER_ID = &quot;single_user&quot;<br><br># Create or get memory resource with strategies for single user<br>memory_resource = memory_manager.get_or_create_memory(<br>    name=&quot;LangGraphSingleUserMemory&quot;,<br>    description=&quot;Memory for single-user LangGraph agent conversations.&quot;,<br>    strategies=[<br>        SummaryStrategy(<br>            name=&quot;ConversationSummarizer&quot;,<br>            namespaces=[f&quot;/summaries/{SINGLE_USER_ID}/{{sessionId}}&quot;]<br>        )<br>    ]<br>)<br><br>memory_id = memory_resource.get(&#39;id&#39;)<br>memory_session_manager = MemorySessionManager(memory_id=memory_id)<br><br># Initialize LangGraph memory for immediate conversation history<br>langgraph_memory = MemorySaver()<br><br># Define the state structure with proper message accumulation<br>class GraphState(TypedDict):<br>    messages: Annotated[List[BaseMessage], add_messages]<br><br># Define the LLM node<br>async def llm_node(state: GraphState) -&gt; GraphState:<br>    &quot;&quot;&quot;Node that processes messages through the LLM.&quot;&quot;&quot;<br>    messages = state[&quot;messages&quot;]<br>    <br>    # Process all messages (including conversation history) through the LLM<br>    response = await llm.ainvoke(messages)<br>    return {&quot;messages&quot;: [response]}<br><br># Create the StateGraph<br>workflow = StateGraph(GraphState)<br><br># Add nodes<br>workflow.add_node(&quot;llm&quot;, llm_node)<br><br># Set entry point<br>workflow.set_entry_point(&quot;llm&quot;)<br><br># Add edge from llm to END<br>workflow.add_edge(&quot;llm&quot;, END)<br><br># Compile the graph with LangGraph memory for immediate history<br>graph = workflow.compile(checkpointer=langgraph_memory)<br><br>@app.entrypoint<br>async def invoke(payload, context: BedrockAgentCoreContext):<br>    &quot;&quot;&quot;Process user prompt through LangGraph with persistent memory.&quot;&quot;&quot;<br>    prompt = payload.get(&quot;prompt&quot;, &quot;What is Agentic AI?&quot;)<br>    <br>    # Handle session_id - can come from context (CLI -s) or payload<br>    session_id = context.session_id or payload.get(&quot;session_id&quot;)<br>    <br>    if session_id is not None:<br>        # session_id provided - validate it<br>        if len(session_id) &lt; 33:<br>            return {<br>                &quot;error&quot;: &quot;session_id must be at least 33 characters long&quot;,<br>                &quot;provided_length&quot;: len(session_id),<br>                &quot;required_length&quot;: 33<br>            }<br>    else:<br>        # No session_id provided - generate one<br>        session_id = f&quot;user-single-conversation-{str(uuid.uuid4())}&quot;<br>    <br>    human_message = HumanMessage(content=prompt)<br>    config = {&quot;configurable&quot;: {&quot;thread_id&quot;: session_id}}<br>    <br>    # Check if LangGraph has existing conversation history<br>    try:<br>        current_state = await graph.aget_state(config)<br>        existing_messages = current_state.values.get(&quot;messages&quot;, []) if current_state and current_state.values else []<br>        has_existing_history = len(existing_messages) &gt; 0<br>    except:<br>        has_existing_history = False<br>    <br>    # Get or create Bedrock memory session<br>    memory_session = None<br>    try:<br>        memory_session = memory_session_manager.create_memory_session(<br>            actor_id=SINGLE_USER_ID,<br>            session_id=session_id<br>        )<br>    except:<br>        pass<br>    <br>    # If no LangGraph history, try to restore context from Bedrock memory<br>    if not has_existing_history and memory_session:<br>        try:<br>            await asyncio.sleep(1)  # Allow Bedrock processing time<br>            <br>            # Try to get session-specific memory records<br>            session_namespace = f&quot;/summaries/{SINGLE_USER_ID}/{session_id}&quot;<br>            memory_records = None<br>            <br>            if hasattr(memory_session, &#39;list_long_term_memory_records&#39;):<br>                memory_records = memory_session.list_long_term_memory_records(<br>                    namespace_prefix=session_namespace<br>                )<br>            <br>            # Extract context from memory records<br>            if memory_records and len(memory_records) &gt; 0:<br>                first_record = memory_records[0]<br>                summary_text = &quot;&quot;<br>                <br>                # Handle MemoryRecord objects<br>                if hasattr(first_record, &#39;content&#39;):<br>                    content_obj = first_record.content<br>                    if isinstance(content_obj, dict):<br>                        summary_text = content_obj.get(&#39;text&#39;, &#39;&#39;).strip()<br>                    elif hasattr(content_obj, &#39;text&#39;):<br>                        summary_text = content_obj.text.strip()<br>                <br>                # Enhance message with context if found<br>                if summary_text:<br>                    enhanced_content = f&quot;Previous conversation context from this session:\n{summary_text}\n\nCurrent question: {prompt}&quot;<br>                    human_message = HumanMessage(content=enhanced_content)<br>                    <br>        except:<br>            pass  # Continue without context if memory retrieval fails<br>    <br>    # Invoke LangGraph<br>    result = await graph.ainvoke({&quot;messages&quot;: [human_message]}, config=config)<br>    last_message = result[&quot;messages&quot;][-1]<br>    ai_response = last_message.content<br>    <br>    # Store conversation in Bedrock memory<br>    if memory_session and hasattr(memory_session, &#39;add_turns&#39;):<br>        try:<br>            user_msg = ConversationalMessage(prompt, MessageRole.USER)<br>            memory_session.add_turns(messages=[user_msg])<br>            <br>            assistant_msg = ConversationalMessage(ai_response, MessageRole.ASSISTANT)<br>            memory_session.add_turns(messages=[assistant_msg])<br>        except:<br>            pass  # Continue if memory storage fails<br>    <br>    return {<br>        &quot;result&quot;: ai_response,<br>        &quot;session_id&quot;: session_id<br>    }<br>if __name__ == &quot;__main__&quot;:<br>    app.run()</pre><p>Next, run : pip install bedrock_agentcore_starter_toolkit in your terminal, add the package.</p><p>Then rerun the server. This would create a new AgentCore Memory, which would take some time:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9eU6tzUNn8-eOHa9a7SjVw.png" /></figure><p>On the console, we see it been active:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wBUpegT0vOqVa2B9CrrRXQ.png" /></figure><p>Now, perform a similar test we did above. Restart the server and ask the last series of questions. We use a new session id of user-123456-conversation-12345679 as AgentCore runtime needs a unique session ID for each user or conversation with at least 33 characters. More info on that <a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-sessions.html">here</a>.</p><pre>agentcore invoke --dev &#39;{&quot;prompt&quot;: &quot;what is the capital of Ghana?&quot;, &quot;session_id&quot;: &quot;user-123456-conversation-12345679&quot;}&#39;      </pre><p>Output:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vu5Wg6yF4O3NMr47iXq_fA.png" /></figure><p>Now, we restart our server and ask</p><pre>agentcore invoke --dev &#39;{&quot;prompt&quot;: &quot;what did I ask last?&quot;, &quot;session_id&quot;: &quot;user-123456-conversation-12345679&quot;}&#39;</pre><p>Output:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*E8_iun3UJVjEIfFqvBMuQg.png" /></figure><p>This time it does remember our conversation!</p><p><em>N:B: From experience, it takes some seconds for the last conversation to reflect in AgentCore Memory and be ready for retrieval.</em></p><h4>Deploy and Test</h4><p>For deployment, we are going with AWS CDK:</p><pre>cd cdk &amp;&amp; npm install &amp;&amp; npm run cdk synth &amp;&amp; npm run cdk:deploy </pre><p>Once deployed, head over to Bedrock AgentCore on the AWS Console:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LXGyT5VI1FO-EgiZB-jKJg.png" /><figcaption>AgentCore</figcaption></figure><p>Clicking on the available runtime:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*MrspBzBNLwxikMz6rbzmfQ.png" /><figcaption>Runtime details</figcaption></figure><p>To test, run:</p><pre>agentcore invoke &#39;{&quot;prompt&quot;: &quot;Who are you?&quot;}&#39; --session-id &#39;user-123456-conversation-87654321&#39;           </pre><p>This time, we pass the session id (user-123456-conversation-87654321) via the --session-id flag, as that&#39;s the way via agentcore invoke in prod to pass a unique session id.</p><p>The response:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Z7jeuEKcA9yKRgcFY3GVXA.png" /></figure><p>When we ask again with a different session id user-123456-conversation-22345678:</p><pre>agentcore invoke &#39;{&quot;prompt&quot;: &quot;what did I ask last?&quot;}&#39; --session-id &#39;user-123456-conversation-22345678&#39;</pre><p>The response:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TcKYzodwJYxxdfdjroIpZQ.png" /></figure><p>This shows the change is session id, created a new session.</p><p>Now, we ask with the session id user-123456-conversation-87654321 which we used to ask the question “Who are you?”:</p><pre>agentcore invoke &#39;{&quot;prompt&quot;: &quot;what did I ask last?&quot;}&#39; --session-id &#39;user-123456-conversation-87654321&#39;</pre><p>And now get this response:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*VWhxZg72VJzrSfWgYQs8Lw.png" /></figure><p>This time it remembers!</p><p><em>Remember to add the needed permissions, like AgentCore Memory, to the AgentCore Runtime role via CDK.</em></p><p>See below:</p><pre>new iam.PolicyStatement({<br>                    sid: &#39;BedrockAgentCoreMemoryAccess&#39;,<br>                    effect: iam.Effect.ALLOW,<br>                    actions: [<br>                        &#39;bedrock-agentcore:ListMemories&#39;,<br>                        &#39;bedrock-agentcore:GetMemory&#39;,<br>                        &#39;bedrock-agentcore:CreateMemory&#39;,<br>                        &#39;bedrock-agentcore:UpdateMemory&#39;,<br>                        &#39;bedrock-agentcore:DeleteMemory&#39;,<br>                        &#39;bedrock-agentcore:CreateEvent&#39;,<br>                        &#39;bedrock-agentcore:CreateMemorySession&#39;,<br>                        &#39;bedrock-agentcore:GetMemorySession&#39;,<br>                        &#39;bedrock-agentcore:UpdateMemorySession&#39;,<br>                        &#39;bedrock-agentcore:DeleteMemorySession&#39;,<br>                        &#39;bedrock-agentcore:ListMemorySessions&#39;,<br>                        &#39;bedrock-agentcore:PutMemoryEvent&#39;,<br>                        &#39;bedrock-agentcore:GetMemoryEvent&#39;,<br>                        &#39;bedrock-agentcore:ListMemoryEvents&#39;,<br>                        &#39;bedrock-agentcore:DeleteMemoryEvent&#39;,<br>                        &#39;bedrock-agentcore:ListLongTermMemoryRecords&#39;,<br>                        &#39;bedrock-agentcore:SearchLongTermMemories&#39;<br>                    ],<br>                    resources: [<br>                        `arn:aws:bedrock-agentcore:${region}:${accountId}:memory/*`,<br>                        `arn:aws:bedrock-agentcore:${region}:${accountId}:memory-session/*`<br>                    ],<br>                }),</pre><h3>Observability</h3><p>LangGraph, in combination with Langsmith, provides a great way to monitor your LangGraph Agents. When combined with CloudWatch Logs and Bedrock AgentCore observability, this provides complete observability, monitoring both application and infrastructure.</p><p>The image gives insight into Memory usage while building this Agent.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*muJ4bYmzN3HKddPRCYxbfQ.png" /></figure><h3>Cleaning Up</h3><p>Remember to destroy all resources when done with testing the app.</p><pre>cdk destroy --all</pre><p>This command deletes the Bedrock AgentCore Agent, ECR image, the IAM roles used by the agent, and any other resource CDK created to launch the project on AWS.</p><p>To delete the memory, run the command below</p><pre>aws bedrock-agentcore-control delete-memory --memory-id &lt;MEMORY_ID&gt;</pre><p>Alternatively, head over to the console and navigate to the Amazon Bedrock AgentCore memory, then delete the created memory.</p><h3>Conclusion</h3><p>I had fun exploring AgentCore as a first-time user, and hopefully you did as well!</p><p>We learnt about this awesome AWS service, and saw how it helps build production-ready AI agents. We also experimented with <strong>Memory</strong>, one of its core services, and how it changes the game in building AI agents that persist across sessions, retain context, and learn.</p><p>Excited to see what you build!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b0cb2876c65b" width="1" height="1" alt=""><hr><p><a href="https://towardsaws.com/building-persistent-memory-agents-with-langgraph-and-amazon-bedrock-agentcore-b0cb2876c65b">Building Persistent Memory Agents with LangGraph and Amazon Bedrock AgentCore</a> was originally published in <a href="https://towardsaws.com/">Towards AWS</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Build an AI Assistant with LangChain, Chroma & AWS Bedrock]]></title>
            <link>https://towardsaws.com/build-an-ai-assistant-with-langchain-chroma-aws-bedrock-a2a35cc962da?source=rss-38b4b5e55906------2</link>
            <guid isPermaLink="false">https://medium.com/p/a2a35cc962da</guid>
            <category><![CDATA[langchain]]></category>
            <category><![CDATA[python]]></category>
            <category><![CDATA[chromadb]]></category>
            <category><![CDATA[streamlit]]></category>
            <category><![CDATA[aws]]></category>
            <dc:creator><![CDATA[Nwachukwu Chibuike]]></dc:creator>
            <pubDate>Tue, 25 Nov 2025 17:24:01 GMT</pubDate>
            <atom:updated>2025-11-26T03:42:56.367Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*m0oyf6KSSZtx1eIx.png" /></figure><p>Recently, I’ve been exploring the Langchain ecosystem and how this powerful framework powers a lot of Generative AI applications. This article, my first with Langchain, is my way of consolidating my findings, insights and sharing them with you.</p><p>In this article, we will build a knowledge management system using Langchain, Chroma, and Bedrock. Users upload documents (PDFs, Markdown), and chat with an AI assistant that answers questions based on the uploaded documents.</p><p>Already excited? Let’s get started</p><h3>Prerequisites</h3><ul><li>AWS account</li><li>AWS CLI setup locally</li><li>Python 3</li></ul><h3>What would we cover</h3><ul><li>We cover what Langchain is</li><li>We cover what Chroma is, and its role in the application</li><li>We cover the role AWS Bedrock plays in the application</li><li>We walk through the Python code.</li><li>We perform some tests to see it in action.</li></ul><h3>What is Langchain</h3><p>Simply put, it is an open-source <strong>orchestration framework</strong> that simplifies the development of applications using large language models (LLMs).</p><p>In our application, it acts as the orchestrator, integrating with both AWS Bedrock and Chroma to give an accurate response based on users query. It achieves this using Retrieval-Augmented Generation (RAG) workflow, where it combines the power of LLMs (AWS Bedorck) with external source(Documents embeddings in Chroma).</p><p>Learn more about it <a href="https://www.langchain.com/">here</a></p><h3>What is Chroma</h3><p><a href="https://www.trychroma.com/">Chroma</a>, is an open-source vector store used for storing and retrieving vector embeddings.</p><p>In our application, we use it to save embeddings along with metadata to be used later by the LLM.</p><p>It achieves this by storing chunked documents plus metadata in a local persistent vector store (chroma_db/ on disk), then uses the AWS Bedrock embedding vectors (generated at ingest time) as its index.</p><p>Given a user question, it runs a similarity search over that index and returns the top‑k Document objects, preserving metadata like source file, chunk index, etc.</p><h3>What is AWS Bedrock</h3><p><a href="https://aws.amazon.com/bedrock/">AWS Bedrock</a>, is a fully managed, serverless service that provides a simple way to build and deploy Gen AI applications. It gives developers access to a variety of industry-leading foundation models (FMs), which can be customized, all while integrating with other AWS services.</p><p>We would focus on the foundational model it provides us with in this article. Acting as the brain power within the application, it plays two key roles:</p><ul><li><strong>Embeddings model</strong> (via, <em>amazon.titan-embed-text-v1</em>): transforms each chunk into a vector during ingestion so <strong>Chroma</strong> can index/search it later</li><li><strong>Chat model</strong> (via, <em>anthropic.claude-3-sonnet-20240229-v1:0</em>): receives the retrieved context plus the user’s question and produces the natural-language answer</li></ul><h3>Project Setup &amp; Overview</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/516/1*plsfke7PzgrxoqCdeSMNaQ.png" /></figure><p>The folder structure is designed as follows:</p><ol><li>The src contains the main files of the app. Here, we define the AWS Bedrock Embedding model, the document processing system, the vector store module, and how we retrieve response using the query system.</li><li>The chroma_db is used by Chroma as a persistent storage.</li><li>The app.py file is the entry point of the application and returns a GUI powered by Streamlit.</li></ol><h4>How it works</h4><p>User Uploads Documents &gt; Document Processor (PDF/Markdown) &gt; Text Chunking &gt; Bedrock Embeddings &gt; ChromaDB Vector Store (Persistent)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*2KBQCYhMKxYizdEu" /><figcaption>Source image from <a href="https://docs.langchain.com/oss/python/langchain/rag">https://docs.langchain.com/oss/python/langchain/rag</a></figcaption></figure><p>Next, when a user queries the AI assistant, it follows this approach.</p><p>Question &gt; LangChain RAG Chain (adds Prompt and retrieves relevant context from Chroma) &gt; AWS Bedrock LLM &gt; Returns Answer to user</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*hpvuP_m0lsQznBOq" /><figcaption>Source image from <a href="https://docs.langchain.com/oss/python/langchain/rag">https://docs.langchain.com/oss/python/langchain/rag</a></figcaption></figure><p>We will review some parts of the codebase, not all of it, however.</p><h4>Creating the Document Processor</h4><pre>&quot;&quot;&quot;Document processing module for PDF and Markdown files.&quot;&quot;&quot;<br>import os<br>from typing import List<br>from pathlib import Path<br>from pypdf import PdfReader<br>from langchain_text_splitters import RecursiveCharacterTextSplitter<br>from langchain_core.documents import Document<br><br>class DocumentProcessor:<br>    &quot;&quot;&quot;Process PDF and Markdown documents for ingestion.&quot;&quot;&quot;<br>    <br>    def __init__(self, chunk_size: int = 1000, chunk_overlap: int = 200):<br>        &quot;&quot;&quot;Initialize the document processor.<br>        <br>        Args:<br>            chunk_size: Size of text chunks<br>            chunk_overlap: Overlap between chunks<br>        &quot;&quot;&quot;<br>        self.text_splitter = RecursiveCharacterTextSplitter(<br>            chunk_size=chunk_size,<br>            chunk_overlap=chunk_overlap,<br>            length_function=len,<br>        )<br>    <br>    def process_pdf(self, file_path: str) -&gt; List[Document]:<br>        &quot;&quot;&quot;Extract text from a PDF file and split into chunks.<br>        <br>        Args:<br>            file_path: Path to the PDF file<br>            <br>        Returns:<br>            List of Document objects<br>        &quot;&quot;&quot;<br>        documents = []<br>        try:<br>            with open(file_path, &quot;rb&quot;) as file:<br>                pdf_reader = PdfReader(file)<br>                text = &quot;&quot;<br>                for page in pdf_reader.pages:<br>                    text += page.extract_text() + &quot;\n&quot;<br>                <br>                # Split text into chunks<br>                chunks = self.text_splitter.split_text(text)<br>                <br>                # Create Document objects with metadata<br>                file_name = Path(file_path).name<br>                for i, chunk in enumerate(chunks):<br>                    documents.append(<br>                        Document(<br>                            page_content=chunk,<br>                            metadata={<br>                                &quot;source&quot;: file_name,<br>                                &quot;file_path&quot;: file_path,<br>                                &quot;chunk_index&quot;: i,<br>                                &quot;file_type&quot;: &quot;pdf&quot;,<br>                            }<br>                        )<br>                    )<br>        except Exception as e:<br>            raise Exception(f&quot;Error processing PDF {file_path}: {str(e)}&quot;)<br>        <br>        return documents<br>    <br>    def process_markdown(self, file_path: str) -&gt; List[Document]:<br>        &quot;&quot;&quot;Extract text from a Markdown file and split into chunks.<br>        <br>        Args:<br>            file_path: Path to the Markdown file<br>            <br>        Returns:<br>            List of Document objects<br>        &quot;&quot;&quot;<br>        documents = []<br>        try:<br>            with open(file_path, &quot;r&quot;, encoding=&quot;utf-8&quot;) as file:<br>                text = file.read()<br>            <br>            # Split text into chunks<br>            chunks = self.text_splitter.split_text(text)<br>            <br>            # Create Document objects with metadata<br>            file_name = Path(file_path).name<br>            for i, chunk in enumerate(chunks):<br>                documents.append(<br>                    Document(<br>                        page_content=chunk,<br>                        metadata={<br>                            &quot;source&quot;: file_name,<br>                            &quot;file_path&quot;: file_path,<br>                            &quot;chunk_index&quot;: i,<br>                            &quot;file_type&quot;: &quot;markdown&quot;,<br>                        }<br>                    )<br>                )<br>        except Exception as e:<br>            raise Exception(f&quot;Error processing Markdown {file_path}: {str(e)}&quot;)<br>        <br>        return documents<br>    <br>    def process_file(self, file_path: str) -&gt; List[Document]:<br>        &quot;&quot;&quot;Process a file based on its extension.<br>        <br>        Args:<br>            file_path: Path to the file<br>            <br>        Returns:<br>            List of Document objects<br>        &quot;&quot;&quot;<br>        file_ext = Path(file_path).suffix.lower()<br>        <br>        if file_ext == &quot;.pdf&quot;:<br>            return self.process_pdf(file_path)<br>        elif file_ext in [&quot;.md&quot;, &quot;.markdown&quot;]:<br>            return self.process_markdown(file_path)<br>        else:<br>            raise ValueError(f&quot;Unsupported file type: {file_ext}&quot;)</pre><p>From the above, we set some methods that process the uploaded file, breaks it into text chunks and finally returns this chunks alongside some metadata.</p><h4>Creating Embeddings with AWS Bedrock</h4><pre>&quot;&quot;&quot;Bedrock embeddings integration module.&quot;&quot;&quot;<br>from typing import List<br>from langchain_aws import BedrockEmbeddings<br>import config<br><br><br>class BedrockEmbeddingService:<br>    &quot;&quot;&quot;Service for generating embeddings using AWS Bedrock.&quot;&quot;&quot;<br>    <br>    def __init__(<br>        self,<br>        model_id: str = None,<br>        region_name: str = None,<br>        aws_access_key_id: str = None,<br>        aws_secret_access_key: str = None,<br>    ):<br>        &quot;&quot;&quot;Initialize the Bedrock embedding service.<br>        <br>        Args:<br>            model_id: Bedrock embedding model ID<br>            region_name: AWS region<br>            aws_access_key_id: AWS access key ID<br>            aws_secret_access_key: AWS secret access key<br>        &quot;&quot;&quot;<br>        self.model_id = model_id or config.BEDROCK_EMBEDDING_MODEL_ID<br>        self.region_name = region_name or config.AWS_REGION<br>        self.aws_access_key_id = aws_access_key_id or config.AWS_ACCESS_KEY_ID<br>        self.aws_secret_access_key = aws_secret_access_key or config.AWS_SECRET_ACCESS_KEY<br><br>        # Initialize Bedrock embeddings<br>        credentials = {}<br>        if self.aws_access_key_id:<br>            credentials[&quot;aws_access_key_id&quot;] = self.aws_access_key_id<br>        if self.aws_secret_access_key:<br>            credentials[&quot;aws_secret_access_key&quot;] = self.aws_secret_access_key<br>      <br>        self.embeddings = BedrockEmbeddings(<br>            model_id=self.model_id,<br>            region_name=self.region_name,<br>            **credentials<br>        )<br>        print(f&quot;AWS Access Key ID: {self.aws_access_key_id}&quot;)<br>        print(f&quot;AWS Secret Access Key: {self.aws_secret_access_key}&quot;)<br>        print(f&quot;AWS Region: {self.region_name}&quot;)<br>        print(f&quot;AWS Model ID: {self.model_id}&quot;)<br><br>    <br>    def get_embeddings_model(self):<br>        &quot;&quot;&quot;Get the LangChain embeddings model instance.<br>        <br>        Returns:<br>            BedrockEmbeddings instance<br>        &quot;&quot;&quot;<br>        return self.embeddings</pre><p>This sets up AWS Bedrock as the embedding model which would later be used by Chroma.</p><h4>Creating the Vector store</h4><pre>&quot;&quot;&quot;ChromaDB vector store integration module.&quot;&quot;&quot;<br>from typing import List<br>import chromadb<br>from chromadb.config import Settings<br>from langchain_chroma import Chroma<br>from langchain_core.documents import Document<br>import config<br>from src.bedrock_embeddings import BedrockEmbeddingService<br><br><br>class VectorStore:<br>    &quot;&quot;&quot;ChromaDB vector store for document embeddings.&quot;&quot;&quot;<br>    <br>    def __init__(self, collection_name: str = None, reset: bool = False):<br>        &quot;&quot;&quot;Initialize the vector store.<br>        <br>        Args:<br>            collection_name: Name of the ChromaDB collection<br>            reset: Whether to reset the collection if it exists<br>        &quot;&quot;&quot;<br>        self.collection_name = collection_name or config.COLLECTION_NAME<br>        self.persist_directory = config.CHROMA_PERSIST_DIRECTORY<br>        <br>        # Initialize embedding service<br>        self.embedding_service = BedrockEmbeddingService()<br>        <br>        # Initialize ChromaDB client<br>        self.client = chromadb.PersistentClient(<br>            path=self.persist_directory,<br>            settings=Settings(<br>                anonymized_telemetry=False,<br>                allow_reset=True<br>            )<br>        )<br>        <br>        # Initialize or get collection<br>        if reset:<br>            try:<br>                self.client.delete_collection(name=self.collection_name)<br>            except:<br>                pass<br>        <br>        # Create LangChain Chroma vector store<br>        self.vector_store = Chroma(<br>            collection_name=self.collection_name,<br>            embedding_function=self.embedding_service.get_embeddings_model(),<br>            persist_directory=self.persist_directory,<br>            client=self.client,<br>        )<br>    <br>    def add_documents(self, documents: List[Document]) -&gt; List[str]:<br>        &quot;&quot;&quot;Add documents to the vector store.<br>        <br>        Args:<br>            documents: List of Document objects to add<br>            <br>        Returns:<br>            List of document IDs<br>        &quot;&quot;&quot;<br>        return self.vector_store.add_documents(documents)<br>    <br>    def get_collection_info(self) -&gt; dict:<br>        &quot;&quot;&quot;Get information about the collection.<br>        <br>        Returns:<br>            Dictionary with collection information<br>        &quot;&quot;&quot;<br>        try:<br>            collection = self.client.get_collection(name=self.collection_name)<br>            count = collection.count()<br>            return {<br>                &quot;name&quot;: self.collection_name,<br>                &quot;document_count&quot;: count,<br>                &quot;status&quot;: &quot;active&quot;<br>            }<br>        except Exception as e:<br>            return {<br>                &quot;name&quot;: self.collection_name,<br>                &quot;document_count&quot;: 0,<br>                &quot;status&quot;: &quot;not_found&quot;,<br>                &quot;error&quot;: str(e)<br>            }</pre><p>This initializes the store, and provides a method for adding documents(chunks) to the vector store.</p><h4>Retrieving results with the Query system</h4><pre>&quot;&quot;&quot;LangChain query and chat module.&quot;&quot;&quot;<br>from typing import List<br>from langchain_core.prompts import PromptTemplate<br>from langchain_core.runnables import RunnablePassthrough<br>from langchain_aws import ChatBedrock<br>from langchain_core.documents import Document<br>from langchain_core.output_parsers import StrOutputParser<br>import config<br>from src.vector_store import VectorStore<br><br><br>class QueryChain:<br>    &quot;&quot;&quot;LangChain-based query and chat system.&quot;&quot;&quot;<br><br>    def __init__(self, vector_store: VectorStore):<br>        &quot;&quot;&quot;Initialize the query chain.<br><br>        Args:<br>            vector_store: VectorStore instance for retrieval<br>        &quot;&quot;&quot;<br>        self.vector_store = vector_store<br><br>        # Initialize Bedrock chat model<br>        credentials = {}<br>        if config.AWS_ACCESS_KEY_ID:<br>            credentials[&quot;aws_access_key_id&quot;] = config.AWS_ACCESS_KEY_ID<br>        if config.AWS_SECRET_ACCESS_KEY:<br>            credentials[&quot;aws_secret_access_key&quot;] = config.AWS_SECRET_ACCESS_KEY<br><br>        self.llm = ChatBedrock(<br>            model_id=config.BEDROCK_CHAT_MODEL_ID,<br>            region_name=config.AWS_REGION,<br>            model_kwargs={&quot;temperature&quot;: 0.7, &quot;max_tokens&quot;: 2000},<br>            **credentials<br>        )<br><br>        # Create custom prompt template<br>        self.prompt_template = PromptTemplate(<br>            template=&quot;&quot;&quot;You are a helpful AI assistant that answers questions based on the provided context from company documents.<br><br>Context from documents:<br>{context}<br><br>Question: {question}<br><br>Instructions:<br>- Answer the question based only on the provided context<br>- If the context doesn&#39;t contain enough information, say so<br>- Be concise and accurate<br>- Use bullet points or numbered lists when appropriate<br>- If asked about something not in the context, politely explain that you don&#39;t have that information<br><br>Answer:&quot;&quot;&quot;,<br>            input_variables=[&quot;context&quot;, &quot;question&quot;]<br>        )<br><br>        # Create retriever<br>        self.retriever = self.vector_store.vector_store.as_retriever(<br>            search_kwargs={&quot;k&quot;: 4}<br>        )<br>        <br>        # Create RAG chain using RunnablePassthrough pattern<br>        def format_docs(docs):<br>            return &quot;\n\n&quot;.join(doc.page_content for doc in docs)<br>        <br>        self.qa_chain = (<br>            {<br>                &quot;context&quot;: self.retriever | format_docs,<br>                &quot;question&quot;: RunnablePassthrough(),<br>            }<br>            | self.prompt_template<br>            | self.llm<br>            | StrOutputParser()<br>        )<br><br>    def query(self, question: str) -&gt; dict:<br>        &quot;&quot;&quot;Query the knowledge base with a question.<br><br>        Args:<br>            question: User&#39;s question<br><br>        Returns:<br>            Dictionary with answer and source documents<br>        &quot;&quot;&quot;<br>        # Get answer from chain<br>        answer = self.qa_chain.invoke(question)<br>        <br>        # Get source documents separately<br>        source_documents = self.retriever.invoke(question)<br><br>        return {<br>            &quot;answer&quot;: answer,<br>            &quot;source_documents&quot;: source_documents,<br>        }</pre><p>Lastly, this combines the users query, Prompt template, retieved context, and LLM to provide a valid answer to the user.</p><h3>Getting Started</h3><p>The source code can be found <a href="https://github.com/chyke007/python-langchain">here</a>.</p><p>Create a virtual environment, and install dependencies:</p><pre>cd python-langchain<br><br>python3 -m venv venv<br>source venv/bin/activate<br><br><br>pip install -r requirements.txt</pre><p>Set up environment variables:</p><pre>cp .env.example .env</pre><p>Edit .env and add your AWS credentials:</p><pre>AWS_REGION=us-east-1 <br>AWS_ACCESS_KEY_ID=your_access_key_here <br>AWS_SECRET_ACCESS_KEY=your_secret_key_here <br>BEDROCK_MODEL_ID=amazon.titan-embed-text-v1 <br>BEDROCK_CHAT_MODEL_ID=anthropic.claude-3-sonnet-20240229-v1:0<br>CHROMA_PERSIST_DIRECTORY=./chroma_db<br>CHUNK_SIZE=1000<br>CHUNK_OVERLAP=200</pre><p>Then launch the app:</p><pre>streamlit run app.py</pre><h3>Testing the app</h3><p>After running the streamlit application, the image below should display as the landing page.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Nrsw300NA1zCngUtuO_Rhw.png" /><figcaption>Landing page</figcaption></figure><p>Click the “<strong><em>Initialize/Reset Vector Store</em></strong>” at the top left corner to proceed.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bprLdp0N-9TnHW7MJLdMnA.png" /><figcaption>Initialized Vector Store</figcaption></figure><p>Currently there are no active documents in the store, lets fix that by testing the application.</p><p>To test the app, upload the <strong><em>sample_document.pdf</em></strong> file, and click the <strong><em>Load Documents</em></strong> button to begin the indexing process.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*7Hrue8BlDhU1FArNKkokOQ.png" /><figcaption>Added Sample Document</figcaption></figure><p>Now that is done, time to test the application!</p><blockquote><em>Query 1</em></blockquote><p><strong>User</strong>: What is the contact info of the Engineering and HR team?</p><p><strong>Assistant</strong>: According to the provided context from the company documents, the contact information for the Engineering and HR teams is:</p><ul><li>Engineering: <a href="/service/mailto:engineering@company.com">engineering@company.com</a></li><li>HR: <a href="/service/mailto:hr@company.com">hr@company.com</a></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BbH5ynN67BgkfVcCcW83Zw.png" /><figcaption>Assistant answer — Query 1</figcaption></figure><p>As can be seen, it also provided the Source Document/Chunks where it retrived this information from.</p><p>Looking at the source material, we can confirm we got back an accurate result.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CNYkofaJI6mGbAudRJvLZA.png" /><figcaption>Context from source material — Query 1</figcaption></figure><blockquote>Query 2</blockquote><p><strong>User</strong>: What are the team’s core working hours, and when should PTO requests be submitted?</p><p><strong>Assistant</strong>: Based on the provided context, here are the relevant details:</p><ul><li>Core working hours:</li><li>Core hours are 10 AM — 3 PM EST for team collaboration</li><li>All employees must be available during these core hours</li><li>PTO requests:</li><li>PTO requests should be submitted at least 2 weeks in advance</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RqdWnYhg3ujQ8UE037DcHQ.png" /><figcaption>Assistant answer — Query 2</figcaption></figure><p>Looking at the source material, we can confirm we got back an accurate result.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*R_KX161fhY7KLPFXBF8RGg.png" /><figcaption>Context from source material — Query 2</figcaption></figure><h4>What’s Next?</h4><p>In my next article, we will learn how to deploy this application for production using Amazon Bedrock AgentCore, and also tweak some parts of the project to experiment with other tools and approaches.</p><p>This would enable end users to test the app from anywhere in the world. Anticipate!</p><p>You can find the complete source code <a href="https://github.com/chyke007/python-langchain">here</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a2a35cc962da" width="1" height="1" alt=""><hr><p><a href="https://towardsaws.com/build-an-ai-assistant-with-langchain-chroma-aws-bedrock-a2a35cc962da">Build an AI Assistant with LangChain, Chroma &amp; AWS Bedrock</a> was originally published in <a href="https://towardsaws.com/">Towards AWS</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[AWS ECS Observability with CDK, OpenTelemetry, ADOT, and Grafana]]></title>
            <link>https://towardsaws.com/aws-ecs-observability-with-cdk-opentelemetry-adot-and-grafana-506513b5b3f9?source=rss-38b4b5e55906------2</link>
            <guid isPermaLink="false">https://medium.com/p/506513b5b3f9</guid>
            <category><![CDATA[opentelemetry]]></category>
            <category><![CDATA[serverless]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[grafana]]></category>
            <category><![CDATA[python]]></category>
            <dc:creator><![CDATA[Nwachukwu Chibuike]]></dc:creator>
            <pubDate>Wed, 15 Oct 2025 06:55:48 GMT</pubDate>
            <atom:updated>2025-10-23T14:53:10.513Z</atom:updated>
            <content:encoded><![CDATA[<p>Ever wanted to deploy a Cloud-native observability the right way for production on AWS? If yes, then this article is right for you.</p><p>In this article, we build a production-ready, cloud-native observability stack on AWS ECS Fargate using CDK, OpenTelemetry, ADOT, and Grafana. This article is an upgrade to the previous article <a href="https://medium.com/towards-aws/serverless-cloud-native-logging-and-visualization-on-aws-ecs-using-cdk-735f9888cb39">here</a>, where we solely focused on logging and visualization using Loki and Grafana.</p><p>Already excited? Let’s get started</p><h3>Prerequisites</h3><ul><li>AWS account</li><li>AWS CLI setup locally</li><li><a href="https://cdkworkshop.com/15-prerequisites.html">AWS CDK</a> set up.</li><li><a href="https://www.python.org/downloads/">Python3</a></li><li><a href="https://www.docker.com/">Docker</a></li><li><a href="https://github.com/features/actions">GitHub Actions</a></li><li>Previous article <a href="https://medium.com/towards-aws/serverless-cloud-native-logging-and-visualization-on-aws-ecs-using-cdk-735f9888cb39">here</a></li></ul><h3>Case Study: Trimbal</h3><p>From the previous article, we saw Trimbal implement a cloud-native logging and visualization solution using Loki and Grafana.</p><p>Management has seen the great impact it had, and wants a complete implementation of full observability using the right tools.</p><p>After research, you learnt about OpenTelemetry and decided to hop on the train and utilize it in this app. You’ve done the research, validated the approach, and now it’s time to <strong>design the architecture and start building</strong>.</p><p>Your proposed solution is:</p><ul><li><strong>Logs</strong>: Loki for log aggregation</li><li><strong>Metrics</strong>: Amazon Managed Service for Prometheus(AMP) for Prometheus metrics</li><li><strong>Traces</strong>: AWS X-Ray for distributed tracing</li><li><strong>Visualization</strong>: Grafana dashboards</li></ul><h3>What is OpenTelemetry?</h3><p><a href="https://opentelemetry.io/"><strong>OTel</strong></a>,<strong> </strong>for short, is an open-source vendor-neutral framework of tools, SDKs, APIS for the generation, collection, and exportation of telemetry data like logs, metrics, and traces. This standard(under CNCF) provides a unified way to instrument applications for observability, without being tied down to a specific vendor.</p><p>One standard for metrics, logs, and traces means less complexity, more visibility.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/379/0*rCeqeXTIYMEB0jnX" /></figure><p>We would be making use of <strong><em>OTel</em></strong> to add full observability to the current implementation. Even AWS is moving away from using X-Ray SDK for collecting traces, and focusing on open-source — <strong><em>OTel</em></strong>. More on that <a href="https://docs.aws.amazon.com/xray/latest/devguide/xray-sdk-migration.html">here</a>.</p><h3>What is AWS Distro for OpenTelemetry?</h3><p><a href="https://aws-otel.github.io/"><strong>ADOT</strong></a>,<strong> </strong>for short, is a secure, production-ready, AWS-supported distribution of the OpenTelemetry project.</p><p>From the official page, we can see that:</p><blockquote>With AWS Distro for OpenTelemetry, you can instrument your applications just once to send correlated metrics and traces to multiple AWS and Partner monitoring solutions. Use auto-instrumentation agents to collect traces without changing your code. AWS Distro for OpenTelemetry also collects metadata from your AWS resources and managed services, so you can correlate application performance data with underlying infrastructure data, reducing the mean time to problem resolution</blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*YJfl2NfS_1VfU7BK.png" /><figcaption>How <strong>ADOT</strong> works</figcaption></figure><p>We would be making use of ADOT to instrument the current application running on Amazon Elastic Container Service (ECS).</p><p>This would be possible by implementing a sidecar pattern, where the <strong>ADOT</strong> collector container would forward metrics and traces to Amazon Managed Service for Prometheus(<strong>AMP</strong>) and AWS <strong>X-Ray</strong> ,respectively, similar to what with did with <strong>Firelens</strong> &amp; <strong>Loki</strong> to forward logs to Loki.</p><h3>Current Architecture</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*8_BwD6V7x2SfaxG4.png" /><figcaption>Current Architecture</figcaption></figure><h3>Proposed Architecture</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GfLVzfPBvj62_II5Cf2qfA.png" /><figcaption>Proposed Architecture</figcaption></figure><p><strong>We can notice some new additions:</strong></p><ol><li><strong>Introduced a new sidecar — </strong>The<strong> ADOT </strong>sidecar collector for forwarding metrics and traces to AWS.</li><li><strong>Grafana</strong> + <strong>Loki</strong> + <strong>AMP</strong> + <strong>X-Ray</strong> integration: Gives us full observability in our app.</li><li>Added <strong>AMP </strong>workspace, <strong>SQS</strong> queues, and <strong>DynamoDB</strong> table.</li></ol><h3>Project Overview</h3><p>We will review some parts of the codebase, not all, however.</p><h4>ECS Stack</h4><pre>....   <br># Add Add AMP permissions, CloudWatch permissions and  X-Ray metrics)<br>        task_role.add_managed_policy(iam.ManagedPolicy.from_aws_managed_policy_name(&quot;AmazonPrometheusFullAccess&quot;))<br>        execution_role.add_managed_policy(iam.ManagedPolicy.from_aws_managed_policy_name(&quot;AmazonPrometheusFullAccess&quot;))<br>        task_role.add_managed_policy(iam.ManagedPolicy.from_aws_managed_policy_name(&quot;CloudWatchFullAccess&quot;))<br>        task_role.add_managed_policy(iam.ManagedPolicy.from_aws_managed_policy_name(&quot;AWSXRayDaemonWriteAccess&quot;))<br>        task_role.add_managed_policy(iam.ManagedPolicy.from_aws_managed_policy_name(&quot;AWSXrayReadOnlyAccess&quot;))<br><br><br># Logger app container<br>        logger_container=logger_task_def.add_container(<br>            &quot;LoggerAppContainer&quot;,<br>            image=ecs.ContainerImage.from_ecr_repository(ecr_logger),<br>            essential=True,<br>            logging=ecs.LogDrivers.firelens(<br>                options={<br>                    &quot;Name&quot;: &quot;grafana-loki&quot;,<br>                    &quot;Url&quot;: &quot;http://loki.internal.com/loki/api/v1/push&quot;,<br>                    #&quot;Labels&quot;: &quot;job=firelens-logs,environment=production&quot;,<br>                    &quot;LabelKeys&quot;: &quot;container_name,ecs_task_definition,source,ecs_cluster&quot;,<br>                    &quot;LineFormat&quot;: &quot;key_value&quot;,<br>                    &quot;RemoveKeys&quot;: &quot;container_id,ecs_task_arn&quot;<br>                }<br>            ),<br>            environment={<br>                &quot;OTEL_SERVICE_NAME&quot;: &quot;logger-app&quot;,<br>                &quot;OTEL_EXPORTER_OTLP_ENDPOINT&quot;: &quot;http://localhost:4317&quot;,<br>                &quot;OTEL_TRACES_SAMPLER&quot;: &quot;parentbased_always_on&quot;,<br>                &quot;AWS_REGION&quot;: &quot;us-east-1&quot;,<br>                &quot;SQS_MESSAGE_QUEUE_URL&quot;: sqs_stack.message_queue.queue_url,<br>                &quot;DYNAMODB_APP_TABLE&quot;: dynamodb_stack.app_table.table_name,<br>                <br>            }<br>        )<br><br><br><br>    adot_collector_container = logger_task_def.add_container(<br>            &quot;AdotCollector&quot;,<br>            image=ecs.ContainerImage.from_registry(&quot;public.ecr.aws/aws-observability/aws-otel-collector:latest&quot;),<br>            essential=False,<br>            logging=ecs.LogDriver.aws_logs(stream_prefix=&quot;adot-collector&quot;),<br>            environment={<br>                &quot;AWS_REGION&quot;: &quot;us-east-1&quot;,<br>                &quot;AWS_XRAY_TRACING_NAME&quot;: &quot;logger-app&quot;,<br>                &quot;OTEL_RESOURCE_ATTRIBUTES&quot;: &quot;service.name=logger-app,service.version=1.0.0&quot;,<br>                &quot;AOT_CONFIG_CONTENT&quot;: f&quot;&quot;&quot;receivers:<br>  otlp:<br>    protocols:<br>      grpc:<br>        endpoint: 0.0.0.0:4317<br>      http:<br>        endpoint: 0.0.0.0:4318<br>  awsecscontainermetrics:<br>    collection_interval: 10s<br>processors:<br>  batch:<br>    timeout: 1s<br>    send_batch_size: 1024<br>  filter:<br>    metrics:<br>      include:<br>        match_type: strict<br>        metric_names:<br>          - ecs.task.memory.utilized<br>          - ecs.task.memory.reserved<br>          - ecs.task.cpu.utilized<br>          - ecs.task.cpu.reserved<br>          - ecs.task.network.rate.rx<br>          - ecs.task.network.rate.tx<br>          - ecs.task.storage.read_bytes<br>          - ecs.task.storage.write_bytes<br>exporters:<br>  awsxray:<br>    region: us-east-1<br>  prometheusremotewrite:<br>    endpoint: {amp_workspace.attr_prometheus_endpoint}api/v1/remote_write<br>    auth:<br>      authenticator: sigv4auth<br>extensions:<br>  health_check:<br>  pprof:<br>    endpoint: :1888<br>  zpages:<br>    endpoint: :55679<br>  sigv4auth:<br>    region: us-east-1<br>    service: aps<br>service:<br>  extensions: [pprof, zpages, health_check, sigv4auth]<br>  pipelines:<br>    traces:<br>      receivers: [otlp]<br>      processors: [batch]<br>      exporters: [awsxray]<br>    metrics:<br>      receivers: [otlp]<br>      processors: [batch]<br>      exporters: [prometheusremotewrite]<br>    metrics/ecs:<br>      receivers: [awsecscontainermetrics]<br>      processors: [filter]<br>      exporters: [prometheusremotewrite]&quot;&quot;&quot;<br>            }<br>        )<br><br>        adot_collector_container.add_port_mappings(<br>            ecs.PortMapping(container_port=4317, protocol=ecs.Protocol.TCP)<br>        )<br>        adot_collector_container.add_port_mappings(<br>            ecs.PortMapping(container_port=4318, protocol=ecs.Protocol.TCP)<br>        )<br>        adot_collector_container.add_port_mappings(<br>            ecs.PortMapping(container_port=1888, protocol=ecs.Protocol.TCP)<br>        )<br>        adot_collector_container.add_port_mappings(<br>            ecs.PortMapping(container_port=55679, protocol=ecs.Protocol.TCP)<br>        )<br>.....</pre><p>We can notice some new additions to the current ECS stack, some of which are not captured above.</p><p>However, let&#39;s highlight the following additions:</p><ul><li>Added necessary permissions to the Task and Execution role.</li><li>Added required environment variables: OTEL_EXPORTER_OTLP_ENDPOINT, and others to the logger container.</li><li>Added the ADOT collector sidecar container as a sidecar to the logger app. As can be noticed, the collector configuration receives some variables, AOT_CONFIG_CONTENT the most important, which defines most of the configuration the container requires. Configurations like where to write the Prometheus metrics to, send the X-Ray traces to, the authentication method to use, and more. This is where you could define third-party vendors as receivers, as defined by <strong>OTel</strong>.</li><li>Exposed required ports that the <strong>ADOT</strong> collector container uses.</li></ul><h4>AMP Stack</h4><pre># AMP (Amazon Managed Prometheus) Stack module<br>from aws_cdk import (<br>    aws_aps as amp,<br>    aws_iam as iam,<br>    Stack<br>)<br>from constructs import Construct<br><br>class AmpStack(Stack):<br>    def __init__(self, scope: Construct, id: str, **kwargs):<br>        super().__init__(scope, id, **kwargs)<br><br>        # Create AMP Workspace<br>        self.workspace = amp.CfnWorkspace(<br>            self, &quot;PrometheusWorkspace&quot;,<br>            alias=&quot;logger-app-prometheus&quot;<br>        )</pre><p>Creates the Amazon Managed Service for Prometheus workspace, which would be used to receive metrics from the application.</p><h4>SQS Stack</h4><pre># SQS Stack module<br>from aws_cdk import (<br>    aws_sqs as sqs,<br>    aws_iam as iam,<br>    Stack,<br>    Duration<br>)<br>from constructs import Construct<br><br>class SqsStack(Stack):<br>    def __init__(self, scope: Construct, id: str, **kwargs):<br>        super().__init__(scope, id, **kwargs)<br><br>        # Create DLQ separately so we can reference its ARN<br>        self.message_dlq = sqs.Queue(<br>            self, &quot;LoggerAppMessageQueueDLQ&quot;,<br>            queue_name=&quot;logger-app-messages-dlq&quot;,<br>            retention_period=Duration.days(14)<br>        )<br><br>        # Create SQS Queue for message processing<br>        self.message_queue = sqs.Queue(<br>            self, &quot;LoggerAppMessageQueue&quot;,<br>            queue_name=&quot;logger-app-messages&quot;,<br>            visibility_timeout=Duration.seconds(30),<br>            retention_period=Duration.days(14),<br>            receive_message_wait_time=Duration.seconds(20),  # Long polling<br>            dead_letter_queue=sqs.DeadLetterQueue(<br>                max_receive_count=3,<br>                queue=self.message_dlq<br>            )<br>        )</pre><p>Creates the SQS queues used in the logger app to receive messages. This was introduced so we could see traces as the application interacts with SQS, mocking production use cases.</p><h4>DynamoDB Stack</h4><pre># DynamoDB Stack module<br>from aws_cdk import (<br>    aws_dynamodb as dynamodb,<br>    aws_iam as iam,<br>    Stack,<br>    RemovalPolicy<br>)<br>from constructs import Construct<br><br>class DynamoDbStack(Stack):<br>    def __init__(self, scope: Construct, id: str, **kwargs):<br>        super().__init__(scope, id, **kwargs)<br><br>        # Create DynamoDB table for application data<br>        self.app_table = dynamodb.Table(<br>            self, &quot;LoggerAppTable&quot;,<br>            table_name=&quot;logger-app-data&quot;,<br>            partition_key=dynamodb.Attribute(<br>                name=&quot;id&quot;,<br>                type=dynamodb.AttributeType.STRING<br>            ),<br>            sort_key=dynamodb.Attribute(<br>                name=&quot;timestamp&quot;,<br>                type=dynamodb.AttributeType.STRING<br>            ),<br>            billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,<br>            removal_policy=RemovalPolicy.DESTROY,<br>            stream=dynamodb.StreamViewType.NEW_AND_OLD_IMAGES<br>        )<br><br>        # Add Global Secondary Index for querying by user<br>        self.app_table.add_global_secondary_index(<br>            index_name=&quot;user-index&quot;,<br>            partition_key=dynamodb.Attribute(<br>                name=&quot;user_id&quot;,<br>                type=dynamodb.AttributeType.STRING<br>            ),<br>            sort_key=dynamodb.Attribute(<br>                name=&quot;created_at&quot;,<br>                type=dynamodb.AttributeType.STRING<br>            )<br>        )</pre><p>Creates the DynamoDB table and indexes used in the logger app to save data. This was introduced so we could see traces as the application interacts with dynamodb, mocking production use cases.</p><h4>Logger Web App</h4><p>A lot of changes were made to the logger app, like addition of new endpoints, integration with Amazon SQS and Amazon DynamoDB, AWS SDK auto-instrumentation, Application instrumentation, implementation of metrics and traces collection and exportation via the OTLP(OTel Protocol).</p><p>This enabled the simulation of a real-world scenario where an application would interact with multiple AWS services, as well as generate custom metrics and traces</p><h3>Deploy Stack</h3><p>Remember to rebuild the Docker images and redeploy to ECR, as we made some new changes.</p><p>Run the following:</p><pre>./bootstrap-ecr-images.sh \<br>  &lt;aws-account-id&gt;.dkr.ecr.us-east-1.amazonaws.com/ecrstack-logger-repo \<br>  &lt;aws-account-id&gt;.dkr.ecr.us-east-1.amazonaws.com/ecrstack-grafana-repo \<br>  &lt;aws-account-id&gt;.dkr.ecr.us-east-1.amazonaws.com/ecrstack-loki-repo</pre><p>Since we added 3 new stacks (<strong><em>AmpStack</em></strong>, <strong><em>DynamoDbStack</em></strong>, <strong><em>SqsStack</em></strong>), we need to add them to the last deploy step we have from the previous article:</p><pre>cdk deploy EcsStack Route53Stack AmpStack DynamoDbStack SqsStack -c vpn_server_cert_arn=$SERVER_CERT_ARN -c vpn_client_cert_arn=$CLIENT_CERT_ARN</pre><p>Replace the $SERVER_CERT_ARN and $CLIENT_CERT_ARN with their respective values.</p><h3>Testing the app</h3><p>To test the app, head over to EC2 and click on the public load balancer that the CDK stack created. You should see the public <strong>DNS name.</strong></p><p>Copy this DNS name, and enter it into a browser or send a GET request. This should show the app landing page running and returning logs!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TfESEO7onSJXRNvcimkLpw.png" /></figure><p>Next, connect to the Client VPN using OpenVPN to access the private VPC and access the private Route53 Grafana and Loki hosted zone.</p><p><em>N:B: Follow the guide for this setup if you haven&#39;t already, from the previous article</em></p><p>Here test Grafana and see the 3 layers, logs, metrics, and traces.</p><h4>Visiting the base route /</h4><p>After visiting the logger app via its ALB on the route /, we then proceed to see the generated logs on Loki via the Grafana dashboard:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*VkvFp6l-ZlwCHQGKUQ9_5g.png" /></figure><p>For Metrics, we see the system-specific metric as well:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tOQJ1JT-RXsefp-LQIn-KQ.png" /></figure><h4>Visiting the route /send-message</h4><p>Next, we make a POST request to the /send-message route to send a message to the SQS queue.</p><p>This returns this response:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*VKRvwZ4J9t7Hp1QRS4ZGOw.png" /></figure><p>For Metrics, we see:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XphXjcHpzpPxxMRj7jZmRw.png" /></figure><p>When we hit the POST request a second time, we see the update:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6OOp4M2Wsk9BkMah4j-v9w.png" /><figcaption>Explore Metrics</figcaption></figure><p>and via DrillDown:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*y6wPoBOwzWNnkSMgTSQQkQ.png" /><figcaption>DrillDown Metrics</figcaption></figure><p>For the Traces, we would check both Grafana and AWS X-Ray.</p><p>For Grafana, we see:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*UIly2gXnc83z2S3wsuxI5Q.png" /></figure><p>Scrolling down, each span contains relevant information that could be further explored by expanding it.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LrmhoCE7k08oTrFgxVfnLQ.png" /></figure><p>For X-Ray/AWS Application Signals, we see:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*y990EPoW6Tsg-bYlzCP7oQ.png" /></figure><p>Looking more into a selected trace, we see more information about the request:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*b34WXqswIQslY419SKsOcw.jpeg" /><figcaption>Trace</figcaption></figure><p>We can also see more details for a selected trace, as well as any metadata it has.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*U7FYuWzRR65jf3DC7DFi3w.jpeg" /><figcaption>Trace details</figcaption></figure><p>A point to note is that custom metadata can be added via the logger app as well.</p><h4>Visiting the route /receive-messages</h4><p>Next, we make a GET request to the /receive-message route to simulate sending a message to the SQS queue.</p><p>This returns this response:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mi51U-1GesoAEoNdG0BE4w.png" /></figure><p>Checking the logs:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DXlEc2CaW0qXDJR8EgTkUg.png" /></figure><p>Checking the Metrics after hitting the /send-message route a few times:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*blssvMpqTZpRhHCFXHAsrw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*uEUTwfEFlKB9OwJ21EMHNA.png" /></figure><p>Checking the Traces on Grafana:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*PXd9dpHaIjYqAIBhF-SsZQ.png" /></figure><p>Expanding:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GCtaRSvF11IhCEs40DhfLw.png" /></figure><h4>Visiting the route /save-data</h4><p>Next, we make a POST request to the /save-data route to save some data into AWS DynamoDB.</p><p>We copy the corresponding id it returns, as we would need it when hitting the get-data route next:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*auJR5LPa0p7xE6ExgkgVkg.png" /></figure><p>We would also review the metrics and traces in the next section when we attempt to retrieve the data.</p><h4>Visiting the route /get-data/{data_id}</h4><p>Next, we make a GET request to the /get-data/{data-id} route to fetch the saved data from AWS DynamoDB.</p><p>Use the id we got from saving the data as the {data-id} to retrieve the data we stored in the previous section:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/678/1*nQXYbiYzOGAeZlKkzldxVA.png" /><figcaption>404 error</figcaption></figure><p>As can be seen, this returns a 404, even though the data exists; this is because we simulated a 404 error, as during querying Dynamodb, we search for timestamp:latest, instead of the right timestamp.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/702/1*qzYWbY_BCW0_elE9Em9L2g.png" /></figure><p>Now, let&#39;s see the metrics and traces for this simulated 404 response:</p><p>Metrics:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tyKCNi15FI1lTsPNd5ysZw.png" /></figure><p>Traces:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*P8MG0cvQbNXdhRlhqCTfig.png" /></figure><p>Expanding, we can see the color change in the Node Graph, as well as the red error on the get_dynamodb_data span, indicating the trace has an issue, just as we expected!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*II57QpqIo5kOIlsSBiZ4uQ.png" /></figure><h4>Visiting the route /workflow</h4><p>Lastly, we make a GET request to the /workflow route to simulate a complete flow involving SQS and DynamoDB.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fcxBpMFhHce4M0Q3XsKsZg.png" /><figcaption>workflow-response</figcaption></figure><p>Checking the Metrics:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*URKRaVoumPsjAkhnnZcxUA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xkDxqCFN3W4toL-Mz9sEVQ.png" /></figure><p>Checking the Traces:</p><p>This shows all green, indicating we have successfully simulated the full workflow:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qxGQMQFmno--0sOTUQaMRw.png" /></figure><p>Expanding, we see more details:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*V7FdYkUgOE5BjLTXZ1vsXw.png" /></figure><p>And that&#39;s a wrap for testing the application.</p><p>We were able to simulate a production use case involving interaction with multiple AWS services, a failure and success scenario, and get to see the full observability in action!</p><h3>Testing GitHub workflow</h3><p>We added the Trivy workflow file (.github/workflows/trivy-scan.yml) to the existing GitHub workflow to enable security scanning.</p><p>Once we push to GitHub, we see the pipeline passes and deploys to AWS.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RfsQsM2Q0unxcbf6scV-oQ.png" /></figure><h3>Conclusion</h3><p>This journey has been both challenging and rewarding. We successfully implemented OpenTelemetry(<strong>OTel</strong>) to achieve comprehensive observability across our application, enabling real-time anomaly detection and maintaining robust security standards. Along the way, we’ve streamlined developer workflows and applied industry best practices to create a production-ready serverless observability solution.</p><p>We successfully combined AWS CDK, OpenTelemetry, ADOT, and Grafana to provide a solid foundation for monitoring modern containerized applications on AWS ECS. By following the patterns and practices outlined in this article, you can also build similar observability solutions that scale with your infrastructure needs.</p><p>You can find the complete source code <a href="https://github.com/chyke007/serverless-cdk-log">here</a></p><p>Excited to see what you build!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=506513b5b3f9" width="1" height="1" alt=""><hr><p><a href="https://towardsaws.com/aws-ecs-observability-with-cdk-opentelemetry-adot-and-grafana-506513b5b3f9">AWS ECS Observability with CDK, OpenTelemetry, ADOT, and Grafana</a> was originally published in <a href="https://towardsaws.com/">Towards AWS</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Serverless Cloud-Native Logging and Visualization on AWS ECS Using CDK]]></title>
            <link>https://towardsaws.com/serverless-cloud-native-logging-and-visualization-on-aws-ecs-using-cdk-735f9888cb39?source=rss-38b4b5e55906------2</link>
            <guid isPermaLink="false">https://medium.com/p/735f9888cb39</guid>
            <category><![CDATA[grafana]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[python]]></category>
            <category><![CDATA[serverless]]></category>
            <category><![CDATA[aws-cdk]]></category>
            <dc:creator><![CDATA[Nwachukwu Chibuike]]></dc:creator>
            <pubDate>Fri, 25 Jul 2025 08:29:07 GMT</pubDate>
            <atom:updated>2025-10-15T09:05:14.031Z</atom:updated>
            <content:encoded><![CDATA[<p>Ever wanted to deploy a Cloud-native logging and visualization tool the right way for production on AWS? If yes, then this article is right for you.</p><p>In this article, we build a production-ready, cloud-native logging and visualization stack on AWS ECS Fargate with Loki, Grafana, Firelens, GitHub(CI/CD), and CDK for automation.</p><p>Already excited? Let’s get started!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*4mQGD4XhmF1z2BwL" /></figure><h3>What would we cover</h3><p>This article stemmed from my own need to get hands-on in multiple AWS core services like Advanced Networking, Security, Storage, Containers/CloudNative, Serverless, and industry best practices like IaC, CI/CD using GitHub, OIDC for third-party authentication, and enabling secure, encrypted connectivity to private VPC networks and AWS resources using AWS Client VPN.</p><p>This article aims to eliminate multiple pain points usually encountered when building apps, like manual configuration, lack of scalability, and insecure patterns, among others.</p><p>We would get to achieve the following:</p><ul><li>Build an app using <strong>AWS CDK </strong>as IaC</li><li>Implement <strong>security architecture</strong> by using VPC, IAM, EFS access point, Security groups, OIDC, AWS Client VPC, OpenSSL, ACM, Private Hosted Zone, and Internal Load Balancer.</li><li>Implement <strong>FireLens for log routing </strong>using a sidecar pattern.</li><li>Use <strong>Loki + Grafana</strong> for Logging and Visualization.</li><li>Implement <strong>Persistent Storage</strong> using EFS and S3.</li><li><strong>Cloud-native</strong> solution using Docker, ECR, and ECS.</li><li>Integrate Continuous Integration &amp; Continuous Deployment(CI/CD) using <strong>GitHub Actions</strong>.</li></ul><h3>Prerequisites</h3><ul><li>AWS account</li><li>AWS CLI setup locally</li><li><a href="https://cdkworkshop.com/15-prerequisites.html">AWS CDK</a> set up.</li><li><a href="https://www.python.org/downloads/">Python3</a></li><li><a href="https://www.docker.com/">Docker</a></li><li><a href="https://github.com/features/actions">GitHub Actions</a></li></ul><h3>Case Study: Trimbal</h3><p>Our imaginary company, <strong>Trimbal</strong>, is preparing to deploy its cloud-native application. The team has chosen <strong>AWS ECS</strong> for container orchestration and initially relies on the default logging solution provided by <strong>CloudWatch</strong>. Everything works fine at first, but as traffic grows, the company realizes it has limited visibility into its logs. Issues start to surface:</p><ul><li><strong>No real-time log streaming</strong></li><li><strong>Limited search capabilities</strong></li><li><strong>A clunky UI with little room for customization</strong> makes it difficult for internal teams to gain actionable insights into customer data.</li></ul><p>Management begins exploring alternatives and hears a lot about <strong>Grafana</strong> and <strong>Loki</strong> as powerful solutions for log visualization and centralized logging. The goal is to implement these tools securely while keeping infrastructure costs under control.</p><p>After reviewing multiple success stories from other companies using Grafana and Loki, the decision is clear — Trimbal will adopt this stack. You’ve done the research, validated the approach, and now it’s time to <strong>design the architecture and start building</strong>.</p><h3>Proposed Architecture</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XcTwPeMGuClDi6lcarIQZg.png" /><figcaption>Architecture diagram</figcaption></figure><p><strong>We can see that:</strong></p><ol><li><strong>Public users visit the Logger Web app via the public-facing load balancer.</strong></li></ol><ul><li>Logger ECS service forwards logs to Loki running in private subnet via Route53 private zone record (<em>loki.internal.com</em>)</li><li>Private Zone points to the internal Load Balancer and routes to the Loki ECS service</li><li>Loki uses EFS for persistent storage, utilizing the file system and access point, and S3 for log archiving</li></ul><p>2. <strong>Authenticated user connects to VPC using any VPN client via the AWS Client VPN credentials</strong></p><ul><li>Establishing a connection with VPC</li><li>Visits <em>grafana.internal.com</em></li><li>Gets routed to the Internal Load Balancer via the Route 53 private zone record (grafana.internal.com)</li><li>Internal Load Balancer points to the Grafana ECS service running in the private subnet</li><li>Grafana uses EFS for persistent storage, utilizing the file system and access point</li></ul><p>3.<strong> GitHub Actions for CI/CD</strong></p><ul><li>Authenticated using OIDC</li><li>Once changes are pushed to the main branch(ignoring some files), a new workflow is triggered</li><li>It builds the Docker images for Logger, Loki, and Grafana</li><li>It runs CDK deploy to deploy any new stack changes</li><li>It updates the ECS task definition for Logger, Loki, and Grafana to use the latest build Docker images, and subsequently updates each service to use the latest task definition.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*sII6Jk2tPN3z5AToHIi9xQ.png" /><figcaption>Architecture in mermaid</figcaption></figure><h3>Why this architecture?</h3><p><strong>Why EFS?</strong></p><ul><li>Used for <em>persistent data storage</em> for both <strong>Grafana</strong> and <strong>Loki</strong>.</li><li>Ensures that <strong>Grafana dashboards, users, sessions, and settings</strong> are preserved even after ECS service redeployments.</li><li>Stores <strong>Loki WAL, index, and cache</strong>, which are crucial for fault-tolerance and high availability.</li><li>Automatically replicated across AZs.</li></ul><p><strong>Why S3?</strong></p><ul><li>Loki writes <strong>compressed log chunks and indexes</strong> to S3</li><li>Cost-effective and scalable storage option vs local ECS disk.</li><li>Enables <strong>long-term log retention</strong> and durability.</li></ul><p><strong>FireLens Sidecar Pattern</strong></p><p><strong>What is it?</strong></p><ul><li>FireLens is an AWS integration with <strong>Fluent Bit</strong> that enables flexible log routing from ECS containers.</li></ul><p><strong>Why use it?</strong></p><ul><li>Offers a <strong>sidecar container</strong> that forwards logs directly to Loki.</li><li>Supports log transformation, filtering, and dynamic labeling (e.g., environment, container name).</li></ul><h3>AWS CDK</h3><p>The <a href="https://docs.aws.amazon.com/cdk/api/v2/docs/aws-construct-library.html">AWS Cloud Development Kit</a> (AWS CDK) is an open-source software development framework for defining cloud resources as code.</p><p>Run this command to set up a new CDK application.</p><pre>sudo npm install -g aws-cdk &amp;&amp; cdk init app - language python</pre><p>Follow the prompt to complete the setup process.</p><h3>Project Overview</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/636/1*ZOHuK3F3kor0dw_P9U1Nww.png" /><figcaption>Folder Setup</figcaption></figure><p>The folder structure is designed as follows:</p><ol><li>The .github/workflows contains the GitHub action workflow file called logger-deploy.yml.</li><li>The app/config contains the Docker files and config for both Loki and Grafana.</li><li>The app/modules contains all the stacks used in the project; that&#39;s where most logic for CDK exists.</li><li>The app/scripts contains the script (generate_and_import_vpn_certs.sh) that generate the certificates that are later used when creating the Client VPN Endpoint. It generates them using OpenSSL and uploads to ACM.</li><li>The sample_logger.py contains the logic for the logger app.</li><li>The architecture contains the architecture_diagram.png file that shows the UI architecture of the project</li></ol><p>We will review some parts of the codebase, not all, however.</p><h4>Logger Web App</h4><pre>import logging<br>import random<br>import time<br>from datetime import datetime<br>from fastapi import FastAPI, Request<br>import threading<br>import uvicorn<br><br># Configure standard logger<br>logging.basicConfig(<br>    level=logging.INFO,<br>    format=&#39;%(asctime)s %(levelname)s %(name)s %(message)s&#39;,<br>    datefmt=&#39;%Y-%m-%dT%H:%M:%S%z&#39;<br>)<br>logger = logging.getLogger(&quot;sample_logger&quot;)<br><br>LOG_LEVELS = [logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.CRITICAL]<br>LOG_LEVEL_NAMES = [&#39;DEBUG&#39;, &#39;INFO&#39;, &#39;WARNING&#39;, &#39;ERROR&#39;, &#39;CRITICAL&#39;]<br>SAMPLE_MESSAGES = [<br>    &quot;User login succeeded&quot;,<br>    &quot;User login failed&quot;,<br>    &quot;File uploaded successfully&quot;,<br>    &quot;File upload failed&quot;,<br>    &quot;Database connection established&quot;,<br>    &quot;Database connection lost&quot;,<br>    &quot;Cache miss&quot;,<br>    &quot;Cache hit&quot;,<br>    &quot;API request received&quot;,<br>    &quot;API request failed&quot;,<br>    &quot;Background job started&quot;,<br>    &quot;Background job completed&quot;,<br>    &quot;Unexpected exception occurred&quot;<br>]<br><br>app = FastAPI()<br><br>def random_log():<br>    while True:<br>        level = random.choice(LOG_LEVELS)<br>        level_name = LOG_LEVEL_NAMES[LOG_LEVELS.index(level)]<br>        msg = random.choice(SAMPLE_MESSAGES)<br>        logger.log(level, f&quot;[{level_name}] {msg}&quot;)<br>        time.sleep(30)<br><br>@app.get(&quot;/&quot;)<br>async def index(request: Request):<br>    now = datetime.utcnow().isoformat()<br>    client_host = request.client.host<br>    logger.info(f&quot;[INFO] Web endpoint visited at {now} from {client_host}. Deployed with Github workflow&quot;)<br>    return {&quot;message&quot;: f&quot;Logger app running. Visit time: {now}. Deployed with Github workflow&quot;}<br><br>if __name__ == &quot;__main__&quot;:<br>    t = threading.Thread(target=random_log, daemon=True)<br>    t.start()<br>    uvicorn.run(app, host=&quot;0.0.0.0&quot;, port=8080)</pre><p>From the above, we spin a server that outputs various levels of logs randomly, and also when a user visits the web app.</p><h4>Route53 Stack</h4><pre>from aws_cdk import (<br>    aws_route53 as route53,<br>    aws_route53_targets as targets,<br>    Stack<br>)<br>from constructs import Construct<br><br>class Route53Stack(Stack):<br>    def __init__(self, scope: Construct, id: str, vpc, internal_alb, **kwargs):<br>        super().__init__(scope, id, **kwargs)<br><br>        # Private hosted zone<br>        self.internal_zone = route53.PrivateHostedZone(<br>            self, &quot;InternalHostedZone&quot;,<br>            zone_name=&quot;internal.com&quot;,<br>            vpc=vpc<br>        )<br>        # A records for loki and grafana<br>        self.loki_record = route53.ARecord(<br>            self, &quot;LokiInternalRecord&quot;,<br>            zone=self.internal_zone,<br>            record_name=&quot;loki.internal.com&quot;,<br>            target=route53.RecordTarget.from_alias(targets.LoadBalancerTarget(internal_alb))<br>        )<br>        self.grafana_record = route53.ARecord(<br>            self, &quot;GrafanaInternalRecord&quot;,<br>            zone=self.internal_zone,<br>            record_name=&quot;grafana.internal.com&quot;,<br>            target=route53.RecordTarget.from_alias(targets.LoadBalancerTarget(internal_alb))<br>        )</pre><p>Creates the Private Zone, as well as <strong>A</strong> records within this zone that point to the internal Load balancer for both Loki and Grafana.</p><h4>ClientVPN Stack</h4><pre>from aws_cdk import (<br>    aws_ec2 as ec2,<br>    Stack,<br>    CfnOutput<br>)<br>from constructs import Construct<br><br>class ClientVpnStack(Stack):<br>    def __init__(self, scope: Construct, id: str, vpc: ec2.Vpc, client_vpn_sg,  **kwargs):<br>        super().__init__(scope, id, **kwargs)<br><br>        # Context values for certificates<br>        server_cert_arn = self.node.try_get_context(&quot;vpn_server_cert_arn&quot;) or &quot;arn:aws:acm:REGION:ACCOUNT:certificate/SERVER_CERT_ID&quot;<br>        client_cert_arn = self.node.try_get_context(&quot;vpn_client_cert_arn&quot;) or &quot;arn:aws:acm:REGION:ACCOUNT:certificate/CLIENT_CERT_ID&quot;<br><br>        # ✅ Client VPN Endpoint<br>        self.client_vpn = ec2.CfnClientVpnEndpoint(<br>            self, &quot;ClientVpnEndpoint&quot;,<br>            authentication_options=[{<br>                &quot;type&quot;: &quot;certificate-authentication&quot;,<br>                &quot;mutualAuthentication&quot;: {<br>                    &quot;clientRootCertificateChainArn&quot;: client_cert_arn<br>                }<br>            }],<br>            client_cidr_block=&quot;10.100.0.0/16&quot;,<br>            connection_log_options={&quot;enabled&quot;: False},<br>            server_certificate_arn=server_cert_arn,<br>            vpc_id=vpc.vpc_id,<br>            security_group_ids=[client_vpn_sg.security_group_id],<br>            split_tunnel=True,<br>            # Uses VPC DNS so internal Route53 works<br>            dns_servers=[&quot;10.0.0.2&quot;],<br>            description=&quot;Client VPN for secure VPC access&quot;<br>        )<br><br>        # ✅ Associate VPN with private subnets<br>        for i, subnet in enumerate(vpc.select_subnets(subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS).subnets):<br>            assoc = ec2.CfnClientVpnTargetNetworkAssociation(<br>                self, f&quot;ClientVpnAssoc{i}&quot;,<br>                client_vpn_endpoint_id=self.client_vpn.ref,<br>                subnet_id=subnet.subnet_id<br>            )<br>        <br>        # ✅ Authorization rule: allow all users (for now)<br>        ec2.CfnClientVpnAuthorizationRule(<br>            self, &quot;ClientVpnAuthRule&quot;,<br>            client_vpn_endpoint_id=self.client_vpn.ref,<br>            target_network_cidr=vpc.vpc_cidr_block,<br>            authorize_all_groups=True<br>        )<br><br>        CfnOutput(self, &quot;ClientVpnEndpointId&quot;, value=self.client_vpn.ref, description=&quot;Client VPN Endpoint ID&quot;)</pre><p>Creates the Client VPN Endpoint, associates it with the existing private subnet in the VPC, and creates an Authorization rule.</p><h3>Pre-Deploy</h3><p>We need to run a script to create a certificate that would be imported to ACM and used by our Client VPN to connect to the VPC.</p><p>We would be using the Mutual Authentication <a href="https://docs.aws.amazon.com/vpn/latest/clientvpn-admin/mutual.html">approach</a>, where the AWS Client VPN uses certificates to perform authentication between the client and the server.</p><p>Create a file generate_and_import_vpn_certs.sh, then make it executable in the app/scripts folder.</p><p>To do that, run this command:</p><pre>mkdir app/scripts &amp;&amp; cd app/scripts &amp;&amp; touch generate_and_import_vpn_certs.sh<br>chmod +x generate_and_import_vpn_certs.sh</pre><p>Add this to the file:</p><pre>#!/bin/bash<br>set -e<br><br># ---- CONFIG ----<br>AWS_REGION=&quot;us-east-1&quot;<br>DOMAIN=&quot;serverlessvpn&quot;<br><br># ---- GENERATE CERTIFICATES ----<br>mkdir -p vpn-certs &amp;&amp; cd vpn-certs<br><br>echo &quot;Generating Root CA...&quot;<br>openssl genrsa -out clientvpn-rootCA.key 2048<br>openssl req -x509 -new -nodes -key clientvpn-rootCA.key -sha256 -days 3650 -out clientvpn-rootCA.crt -subj &quot;/CN=ClientVPN-RootCA&quot;<br><br>echo &quot;Generating Server Certificate...&quot;<br>openssl genrsa -out clientvpn-server.key 2048<br>openssl req -new -key clientvpn-server.key -out clientvpn-server.csr -subj &quot;/CN=server.${DOMAIN}&quot;<br>openssl x509 -req -in clientvpn-server.csr -CA clientvpn-rootCA.crt -CAkey clientvpn-rootCA.key -CAcreateserial -out clientvpn-server.crt -days 3650 -sha256<br><br>echo &quot;Generating Client Certificate...&quot;<br>openssl genrsa -out clientvpn-client.key 2048<br>openssl req -new -key clientvpn-client.key -out clientvpn-client.csr -subj &quot;/CN=client.${DOMAIN}&quot;<br>openssl x509 -req -in clientvpn-client.csr -CA clientvpn-rootCA.crt -CAkey clientvpn-rootCA.key -CAcreateserial -out clientvpn-client.crt -days 3650 -sha256<br><br># ---- IMPORT TO ACM ----<br>echo &quot;Importing Server Certificate to ACM...&quot;<br>SERVER_CERT_ARN=$(aws acm import-certificate \<br>  --certificate fileb://clientvpn-server.crt \<br>  --private-key fileb://clientvpn-server.key \<br>  --certificate-chain fileb://clientvpn-rootCA.crt \<br>  --region $AWS_REGION \<br>  --query CertificateArn --output text)<br><br>echo &quot;Importing Client Root CA to ACM...&quot;<br>CLIENT_CERT_ARN=$(aws acm import-certificate \<br>  --certificate fileb://clientvpn-rootCA.crt \<br>  --private-key fileb://clientvpn-rootCA.key \<br>  --region $AWS_REGION \<br>  --query CertificateArn --output text)<br><br>echo &quot;----&quot;<br>echo &quot;Server Certificate ARN: $SERVER_CERT_ARN&quot;<br>echo &quot;Client Root CA ARN:     $CLIENT_CERT_ARN&quot;<br>echo &quot;----&quot;<br>echo &quot;Use these ARNs in your CDK deployment:&quot;<br>echo &quot;  cdk deploy -c vpn_server_cert_arn=$SERVER_CERT_ARN -c vpn_client_cert_arn=$CLIENT_CERT_ARN&quot;<br>echo &quot;----&quot;<br>echo &quot;Distribute these files to VPN users:&quot;<br>echo &quot;  clientvpn-client.crt, clientvpn-client.key, clientvpn-rootCA.crt&quot;</pre><p>Then run this script.</p><p>This would import the Server Certificate and Client Root CA to ACM.</p><p>It also outputs certificate files to the app/scripts/vpn-certs folder. We would copy the contents of some files from this folder when we need to connect to the VPC using the Client VPN.</p><h3>Deploy the Stack</h3><p>We would break the deployment into 3 stages.</p><p>For the first stage, run the command below to deploy some initial stacks:</p><pre>cdk deploy VpcStack ClientVpnStack SgStack S3Stack EcrStack AlbStack -c vpn_server_cert_arn=$SERVER_CERT_ARN -c vpn_client_cert_arn=$CLIENT_CERT_ARN</pre><p>Replace the $SERVER_CERT_ARN and $CLIENT_CERT_ARN with the right value from the <strong><em>Pre-Deploy</em></strong> step above.</p><p>Before running the next stack deploy, we need to build images for the logger, Grafana, and Loki services. This would ensure the images are in ECR when ECS tries to start up.</p><p>We do this manually for the first time; subsequently, GitHub Actions will be used to build new images and update the task definition and update each service, respectively.</p><p>Run the following:</p><pre>./bootstrap-ecr-images.sh \<br>  &lt;aws-account-id&gt;.dkr.ecr.us-east-1.amazonaws.com/ecrstack-logger-repo \<br>  &lt;aws-account-id&gt;.dkr.ecr.us-east-1.amazonaws.com/ecrstack-grafana-repo \<br>  &lt;aws-account-id&gt;.dkr.ecr.us-east-1.amazonaws.com/ecrstack-loki-repo</pre><p>Replace the &lt;aws-account-id&gt; with your AWS account ID.</p><p>For the second stage, run the next deploy:</p><pre>cdk deploy EfsStack EfsAccessPointsStack -c vpn_server_cert_arn=$SERVER_CERT_ARN -c vpn_client_cert_arn=$CLIENT_CERT_ARN</pre><p>Before running ECS, we need to update the EFS file system permission to enable IAM authentication.</p><p>Due to CDK limitations, we can&#39;t do this directly in CDK as it would create a circular dependency. Hence, we would do it via CLI.</p><p>Run this code to delete the default policy created, so it can use the IAM permissions we have set up.</p><pre>aws efs delete-file-system-policy --file-system-id fs-&lt;id&gt;</pre><p>Replace fs-&lt;id&gt; with the valid file system ID</p><p>Next, deploy the Route53Stack and the EcsStack.</p><pre>cdk deploy EcsStack Route53Stack -c vpn_server_cert_arn=$SERVER_CERT_ARN -c vpn_client_cert_arn=$CLIENT_CERT_ARN</pre><h3>Testing the app</h3><p>To test the app, head over to EC2 and click on the public load balancer that the CDK stack created. You should see the public <strong>DNS name.</strong></p><p>Copy this DNS name, and enter it into a browser. This should show the app running and returning logs!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3Fc3N3fURpAjYGndtxKe-A.png" /></figure><h3>Connecting to the VPC via the AWS Client VPN</h3><p>To confirm that logs are being forwarded securely to Loki, we need to see them. We use Grafana for visibility, which is only accessible from within the VPC.</p><p>Visiting the <em>grafana.internal.com</em> URL would return the image below. This is because we are not currently within the VPC, so it looks for that URL on the public internet:</p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*wO5nJsGfOrT0xx8nhhxXxA.png" /><figcaption>internal.com on the internet</figcaption></figure><p>Let&#39;s fix this!</p><p>We created a ClientVPNStack for this purpose. We will be connecting from our machine using the VPN we created, so as to access the app within the VPC.</p><p>We use <a href="https://openvpn.net/">OpenVPN</a> as the software for connecting; it&#39;s quick to download and set up.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fmk1KKgzw1RnUZ9nfFGaiw.png" /></figure><p>After setup, head over to AWS to download the needed configuration. In the Vpc -&gt; Client VPN endpoints, select the client endpoint CDK created, and click the “<strong>Download client configuration” </strong>button.</p><p>This would download a .ovpn file, which is what we would use in <strong>OpenVPN</strong> to authenticate.</p><p>Open the file in a file editor to make the necessary updates to it, and add the following just below the &lt;ca&gt;… &lt;/ca&gt;:</p><pre>&lt;cert&gt;<br>-----BEGIN CERTIFICATE-----<br>clientvpn-client.crt contents from app/scripts/vpn-certs<br><br>-----END CERTIFICATE-----<br>&lt;/cert&gt;<br><br>&lt;key&gt;<br>-----BEGIN PRIVATE KEY-----<br>clientvpn-client.key contents from app/scripts/vpn-certs<br>-----END PRIVATE KEY-----<br>&lt;/key&gt;</pre><p>Next, save this and upload it to OpenVPN Connect. If everything goes fine, you should see:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/798/1*WIzCrEbztu1Uite3fhGyvQ.png" /></figure><p>Now time to test this out.</p><p>Head over to your browser and enter <a href="http://grafana.internal.com./">http://grafana.internal.com.</a></p><p>We now see the Grafana login page:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*NrzfnUJ2t6ombkWa-PL6SA.png" /></figure><p>Log in with the default credentials of “admin” for username and “admin” for password.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*93fBjQzs5Ovr2MI-272lyw.png" /></figure><p>After logging in, we see that the “<em>Add your first data source</em>” has already been completed. This is because in the Grafana Docker image built, we configured this.</p><p>The Docker file included a command that pushed a prebuilt config file to the granafa image.</p><p>The Dockfile for Grafana:</p><pre>FROM grafana/grafana-oss<br><br>COPY app/config/grafana/grafana-config.yaml /etc/grafana/provisioning/datasources/datasources.yaml</pre><p>The grafana-config.yaml file</p><pre>apiVersion: 1<br><br>datasources:<br>  - name: Loki<br>    type: loki<br>    access: proxy<br>    orgId: 1<br>    url: http://loki.internal.com:80<br>    version: 1<br>    editable: true</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WdhSikk50j2KSjyBDRkQXg.png" /></figure><p>Let&#39;s explore the logs the logger app generated:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZdeZ_zh9ma4LrtjGZ5X1Rg.png" /></figure><p>Clicking on the “<em>Show logs</em>” button, we see the logs:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jJ0xTsvnHhX_szfFKlOa2w.png" /></figure><p>And just like that, we have the full power of Grafana, and we can explore!</p><p>We also see different labels:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*V1d59GFE4RqSG5XfLWPibw.png" /><figcaption>Grafana Logs Lables</figcaption></figure><p>And once we turn off the connection with OpenVPN, we can no longer access Grafana:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wO5nJsGfOrT0xx8nhhxXxA.png" /></figure><h3>Testing GitHub workflow</h3><p>The last step is integrating the GitHub workflow. This would fully automate the app to enable redeployment for each ECS service application once there is a change, as well as update the stack once there is a new change.</p><p>Create a new file in .github/workflow/ folder called logger-deploy.yml with the following content:</p><pre>name: Build and Deploy Logger to ECS<br><br>on:<br>  push:<br>    branches: [ main ]<br>    paths:<br>      - &#39;app/sample_logger.py&#39;<br>      - &#39;Dockerfile&#39;<br>      - &#39;.github/workflows/logger-deploy.yml&#39;<br>      - &#39;requirements.txt&#39;<br>      - &#39;app.py&#39;<br>      - &#39;app/**&#39;<br>      - &#39;cdk.json&#39;<br><br>env:<br>  ECR_REPO: ecrstack-logger-repo<br>  ECR_GRAFANA_REPO: ecrstack-grafana-repo<br>  ECR_LOKI_REPO: ecrstack-loki-repo<br>  ECS_CLUSTER: ecsstack-cluster<br>  LOGGER_SERVICE: logger-service<br>  GRAFANA_SERVICE: grafana-service<br>  LOKI_SERVICE: loki-service<br>  AWS_REGION: us-east-1<br>  IMAGE_TAG: ${{ github.sha }}<br>  SERVER_CERT_ARN:  ${{ secrets.SERVER_CERT_ARN }}<br>  CLIENT_CERT_ARN:  ${{ secrets.CLIENT_CERT_ARN }}<br><br>permissions:<br>      id-token: write<br>      contents: read<br><br>jobs:<br>  build-and-push:<br>    runs-on: ubuntu-latest<br>    steps:<br>      - name: Checkout code<br>        uses: actions/checkout@v3<br><br>      - name: Configure AWS credentials<br>        uses: aws-actions/configure-aws-credentials@v2<br>        with:<br>          role-to-assume: ${{ secrets.ASSUME_ROLE_ARN }}<br>          role-session-name: GitHub_to_AWS_via_FederatedOIDC<br>          aws-region: ${{ env.AWS_REGION }}<br><br>      - name: Login to Amazon ECR<br>        id: login-ecr<br>        uses: aws-actions/amazon-ecr-login@v2<br><br>      - name: Build Logger Docker image<br>        env:<br>          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}<br>        run: |<br>          docker build -t logger-app:${IMAGE_TAG} .<br>          docker tag logger-app:${IMAGE_TAG} $ECR_REGISTRY/$ECR_REPO:${IMAGE_TAG}<br><br>      - name: Push Logger Docker image to ECR<br>        env:<br>          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}<br>        run: |<br>          docker push $ECR_REGISTRY/$ECR_REPO:${IMAGE_TAG}<br><br>      - name: Build Grafana Docker image<br>        env:<br>          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}<br>        run: |<br>          docker build -f app/config/grafana/Dockerfile -t grafana-app:${IMAGE_TAG} .<br>          docker tag grafana-app:${IMAGE_TAG} $ECR_REGISTRY/$ECR_GRAFANA_REPO:${IMAGE_TAG}<br><br>      - name: Push Grafana Docker image to ECR<br>        env:<br>          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}<br>        run: |<br>          docker push $ECR_REGISTRY/$ECR_GRAFANA_REPO:${IMAGE_TAG}<br><br>      - name: Build Loki Docker image<br>        env:<br>          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}<br>        run: |<br>          docker build -f app/config/loki/Dockerfile -t loki-app:${IMAGE_TAG} .<br>          docker tag loki-app:${IMAGE_TAG} $ECR_REGISTRY/$ECR_LOKI_REPO:${IMAGE_TAG}<br><br>      - name: Push Loki Docker image to ECR<br>        env:<br>          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}<br>        run: |<br>          docker push $ECR_REGISTRY/$ECR_LOKI_REPO:${IMAGE_TAG}<br><br>  deploy:<br>    needs: build-and-push<br>    runs-on: ubuntu-latest<br>    steps:<br>      - name: Checkout code<br>        uses: actions/checkout@v3<br><br>      - name: Configure AWS credentials<br>        uses: aws-actions/configure-aws-credentials@v2<br>        with:<br>          role-to-assume: ${{ secrets.ASSUME_ROLE_ARN }}<br>          role-session-name: GitHub_to_AWS_via_FederatedOIDC<br>          aws-region: ${{ env.AWS_REGION }}<br><br>      - name: Login to Amazon ECR<br>        id: login-ecr-update<br>        uses: aws-actions/amazon-ecr-login@v2<br><br>      - name: Install CDK<br>        run: npm install -g aws-cdk<br><br>      - name: Install Python dependencies<br>        run: pip install -r requirements.txt<br><br>      - name: Deploy CDK stack<br>        run: |<br>          cdk deploy --all \<br>          -c vpn_server_cert_arn=&quot;$SERVER_CERT_ARN&quot; \<br>          -c vpn_client_cert_arn=&quot;$CLIENT_CERT_ARN&quot; \<br>          --require-approval never<br>      <br>      - name: Update Logger ECS service with new image<br>        env:<br>          ECR_REGISTRY: ${{ steps.login-ecr-update.outputs.registry }}<br>        run: |<br>          TASK_DEF_ARN=$(aws ecs describe-services --cluster $ECS_CLUSTER --services $LOGGER_SERVICE --region $AWS_REGION --query &quot;services[0].taskDefinition&quot; --output text)<br>          aws ecs describe-task-definition --task-definition $TASK_DEF_ARN --region $AWS_REGION &gt; logger-task-def.json<br>          cat logger-task-def.json | jq &#39;.taskDefinition | {family, executionRoleArn, taskRoleArn, networkMode, containerDefinitions, requiresCompatibilities, cpu, memory, volumes}&#39; &gt; logger-task-def-min.json<br>          IMAGE_URI=&quot;$ECR_REGISTRY/$ECR_REPO:$IMAGE_TAG&quot;<br>          cat logger-task-def-min.json | jq --arg IMAGE_URI &quot;$IMAGE_URI&quot; &#39; .containerDefinitions[0].image = $IMAGE_URI &#39; &gt; logger-task-def-updated.json<br>          NEW_TASK_DEF_ARN=$(aws ecs register-task-definition --cli-input-json file://logger-task-def-updated.json --region $AWS_REGION --query &#39;taskDefinition.taskDefinitionArn&#39; --output text)<br>          aws ecs update-service --cluster $ECS_CLUSTER --service $LOGGER_SERVICE --task-definition $NEW_TASK_DEF_ARN --region $AWS_REGION<br><br>      - name: Update Grafana ECS service with new image<br>        env:<br>          ECR_REGISTRY: ${{ steps.login-ecr-update.outputs.registry }}<br>        run: |<br>          TASK_DEF_ARN=$(aws ecs describe-services --cluster $ECS_CLUSTER --services $GRAFANA_SERVICE --region $AWS_REGION --query &quot;services[0].taskDefinition&quot; --output text)<br>          aws ecs describe-task-definition --task-definition $TASK_DEF_ARN --region $AWS_REGION &gt; grafana-task-def.json<br>          cat grafana-task-def.json | jq &#39;.taskDefinition | {family, executionRoleArn, taskRoleArn, networkMode, containerDefinitions, requiresCompatibilities, cpu, memory, volumes}&#39; &gt; grafana-task-def-min.json<br>          IMAGE_URI=&quot;$ECR_REGISTRY/$ECR_GRAFANA_REPO:$IMAGE_TAG&quot;<br>          cat grafana-task-def-min.json | jq --arg IMAGE_URI &quot;$IMAGE_URI&quot; &#39; .containerDefinitions[0].image = $IMAGE_URI &#39; &gt; grafana-task-def-updated.json<br>          NEW_TASK_DEF_ARN=$(aws ecs register-task-definition --cli-input-json file://grafana-task-def-updated.json --region $AWS_REGION --query &#39;taskDefinition.taskDefinitionArn&#39; --output text)<br>          aws ecs update-service --cluster $ECS_CLUSTER --service $GRAFANA_SERVICE --task-definition $NEW_TASK_DEF_ARN --region $AWS_REGION<br><br>      - name: Update Loki ECS service with new image<br>        env:<br>          ECR_REGISTRY: ${{ steps.login-ecr-update.outputs.registry }}<br>        run: |<br>          TASK_DEF_ARN=$(aws ecs describe-services --cluster $ECS_CLUSTER --services $LOKI_SERVICE --region $AWS_REGION --query &quot;services[0].taskDefinition&quot; --output text)<br>          aws ecs describe-task-definition --task-definition $TASK_DEF_ARN --region $AWS_REGION &gt; loki-task-def.json<br>          cat loki-task-def.json | jq &#39;.taskDefinition | {family, executionRoleArn, taskRoleArn, networkMode, containerDefinitions, requiresCompatibilities, cpu, memory, volumes}&#39; &gt; loki-task-def-min.json<br>          IMAGE_URI=&quot;$ECR_REGISTRY/$ECR_LOKI_REPO:$IMAGE_TAG&quot;<br>          cat loki-task-def-min.json | jq --arg IMAGE_URI &quot;$IMAGE_URI&quot; &#39; .containerDefinitions[0].image = $IMAGE_URI &#39; &gt; loki-task-def-updated.json<br>          NEW_TASK_DEF_ARN=$(aws ecs register-task-definition --cli-input-json file://loki-task-def-updated.json --region $AWS_REGION --query &#39;taskDefinition.taskDefinitionArn&#39; --output text)<br>          aws ecs update-service --cluster $ECS_CLUSTER --service $LOKI_SERVICE --task-definition $NEW_TASK_DEF_ARN --region $AWS_REGION</pre><p>We will use this <a href="https://aws.amazon.com/blogs/security/use-iam-roles-to-connect-github-actions-to-actions-in-aws/">guide</a> to set up an <a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html">IAM OIDC identity provider</a> for GitHub.</p><p>Create an inline policy with this content when creating the role:</p><pre>{<br>    &quot;Version&quot;: &quot;2012-10-17&quot;,<br>    &quot;Statement&quot;: [<br>        {<br>            &quot;Sid&quot;: &quot;ECRPermissions&quot;,<br>            &quot;Effect&quot;: &quot;Allow&quot;,<br>            &quot;Action&quot;: [<br>                &quot;ecr:GetAuthorizationToken&quot;,<br>                &quot;ecr:BatchCheckLayerAvailability&quot;,<br>                &quot;ecr:InitiateLayerUpload&quot;,<br>                &quot;ecr:UploadLayerPart&quot;,<br>                &quot;ecr:CompleteLayerUpload&quot;,<br>                &quot;ecr:PutImage&quot;<br>            ],<br>            &quot;Resource&quot;: &quot;*&quot;<br>        },<br>        {<br>            &quot;Sid&quot;: &quot;ECSPermissions&quot;,<br>            &quot;Effect&quot;: &quot;Allow&quot;,<br>            &quot;Action&quot;: [<br>                &quot;ecs:DescribeServices&quot;,<br>                &quot;ecs:DescribeTaskDefinition&quot;,<br>                &quot;ecs:RegisterTaskDefinition&quot;,<br>                &quot;ecs:UpdateService&quot;<br>            ],<br>            &quot;Resource&quot;: &quot;*&quot;<br>        },<br>        {<br>            &quot;Sid&quot;: &quot;IAMPassRole&quot;,<br>            &quot;Effect&quot;: &quot;Allow&quot;,<br>            &quot;Action&quot;: &quot;iam:PassRole&quot;,<br>            &quot;Resource&quot;: [<br>                &quot;*&quot;<br>            ]<br>        },<br>        {<br>            &quot;Sid&quot;: &quot;CDKDeployment&quot;,<br>            &quot;Effect&quot;: &quot;Allow&quot;,<br>            &quot;Action&quot;: [<br>                &quot;cloudformation:*&quot;,<br>                &quot;s3:*&quot;,<br>                &quot;ec2:*&quot;,<br>                &quot;elasticloadbalancing:*&quot;,<br>                &quot;route53:*&quot;,<br>                &quot;logs:*&quot;,<br>                &quot;acm:*&quot;,<br>                &quot;iam:GetRole&quot;,<br>                &quot;iam:CreateRole&quot;,<br>                &quot;iam:AttachRolePolicy&quot;,<br>                &quot;iam:DetachRolePolicy&quot;,<br>                &quot;iam:DeleteRole&quot;,<br>                &quot;iam:PutRolePolicy&quot;,<br>                &quot;iam:DeleteRolePolicy&quot;<br>            ],<br>            &quot;Resource&quot;: &quot;*&quot;<br>        },<br>        {<br>            &quot;Sid&quot;: &quot;STSGetCallerIdentity&quot;,<br>            &quot;Effect&quot;: &quot;Allow&quot;,<br>            &quot;Action&quot;: &quot;sts:GetCallerIdentity&quot;,<br>            &quot;Resource&quot;: &quot;*&quot;<br>        },<br>        {<br>            &quot;Sid&quot;: &quot;SSMGetPermission&quot;,<br>            &quot;Effect&quot;: &quot;Allow&quot;,<br>            &quot;Action&quot;: &quot;ssm:GetParameter&quot;,<br>            &quot;Resource&quot;: &quot;*&quot;<br>        }<br>    ]<br>}</pre><p>Once that is done, and the role is created, your trust policy would look something like this:</p><pre>{<br>    &quot;Version&quot;: &quot;2012-10-17&quot;,<br>    &quot;Statement&quot;: [<br>        {<br>            &quot;Effect&quot;: &quot;Allow&quot;,<br>            &quot;Principal&quot;: {<br>                &quot;Federated&quot;: &quot;arn:aws:iam::&lt;account-id&gt;:oidc-provider/token.actions.githubusercontent.com&quot;<br>            },<br>            &quot;Action&quot;: &quot;sts:AssumeRoleWithWebIdentity&quot;,<br>            &quot;Condition&quot;: {<br>                &quot;StringEquals&quot;: {<br>                    &quot;token.actions.githubusercontent.com:aud&quot;: &quot;sts.amazonaws.com&quot;<br>                },<br>                &quot;StringLike&quot;: {<br>                    &quot;token.actions.githubusercontent.com:sub&quot;: &quot;repo:&lt;github-username&gt;/&lt;repository-name&gt;:*&quot;<br>                }<br>            }<br>        }<br>    ]<br>}</pre><p>Next, add the required secrets in GitHub:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/904/1*_IGgKWMu5F4XDhXYLeSY2A.png" /></figure><p>Before pushing to GitHub to trigger the workflow, we make a minor change to the logger app.</p><p>Updating the sample_logger.py file from the below:</p><pre>async def index(request: Request):<br>    now = datetime.utcnow().isoformat()<br>    client_host = request.client.host<br>    logger.info(f&quot;[INFO] Web endpoint visited at {now} from {client_host}&quot;)<br>    return {&quot;message&quot;: f&quot;Logger app running. Visit time: {now}&quot;}</pre><p>to:</p><pre>async def index(request: Request):<br>    now = datetime.utcnow().isoformat()<br>    client_host = request.client.host<br>    logger.info(f&quot;[INFO] Web endpoint visited at {now} from {client_host}. Deployed with Github workflow&quot;)<br>    return {&quot;message&quot;: f&quot;Logger app running. Visit time: {now}. Deployed with Github workflow&quot;}</pre><p>Next, commit your changes and push to GitHub on the main branch. This should trigger an action run.</p><p>The running workflow:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*x0bZKzjt51mtuV0aUeYFgQ.png" /></figure><p>We now see the ECS service is updating</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kSGqhlP5AW4c33_0zRKx5g.png" /></figure><p>Once deployment is completed, we visit and see the public app to see if the changes are reflected.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*MGI9VNI3D1tHhWeTD4qErw.png" /></figure><p>Visiting Grafana, we also see the new updated log:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dw70ln-FaEKQiY9Z8d0eJA.png" /></figure><h3>Conclusion</h3><p>This has been an interesting ride. We got to explore multiple core areas in AWS, implemented various best practices, and designed a production-ready serverless cloud-native logging and visualization application from the ground up.</p><p>You can find the complete source code <a href="https://github.com/chyke007/serverless-cdk-log">here</a>. In the next article <a href="https://towardsaws.com/aws-ecs-observability-with-cdk-opentelemetry-adot-and-grafana-506513b5b3f9">here</a>, we implement full observability in the application.</p><p>Excited to see what you build!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=735f9888cb39" width="1" height="1" alt=""><hr><p><a href="https://towardsaws.com/serverless-cloud-native-logging-and-visualization-on-aws-ecs-using-cdk-735f9888cb39">Serverless Cloud-Native Logging and Visualization on AWS ECS Using CDK</a> was originally published in <a href="https://towardsaws.com/">Towards AWS</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building a Serverless Real-Time Data Pipeline with Terraform, AWS, and Python]]></title>
            <link>https://towardsaws.com/building-a-serverless-real-time-data-pipeline-with-terraform-aws-and-python-07111302ec52?source=rss-38b4b5e55906------2</link>
            <guid isPermaLink="false">https://medium.com/p/07111302ec52</guid>
            <category><![CDATA[terraform]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[realtime]]></category>
            <category><![CDATA[data-engineering]]></category>
            <category><![CDATA[python]]></category>
            <dc:creator><![CDATA[Nwachukwu Chibuike]]></dc:creator>
            <pubDate>Wed, 07 May 2025 16:55:31 GMT</pubDate>
            <atom:updated>2025-05-07T17:16:40.854Z</atom:updated>
            <content:encoded><![CDATA[<p>Recently, I’ve been learning and experimenting a lot with AWS data services in my free time, and I find them quite fascinating.</p><p>In this article, we’ll explore how I built a real-time serverless data pipeline using AWS services such as Data Catalog, DataBrew, DynamoDB, and seamlessly deployed it to AWS with the help of Terraform.</p><p>Whether you are new to the world of data engineering or a seasoned expert, this guide has you covered!</p><h3>Prerequisites</h3><ul><li><a href="https://aws.amazon.com/">AWS</a> account</li><li><a href="https://www.python.org/downloads/">Python</a> 3</li><li><a href="https://developer.hashicorp.com/terraform/install">Terraform</a></li></ul><h3>Why Terraform</h3><ul><li>Open source</li><li>Large developer community</li><li>Immutable Infrastructure</li><li>IaC</li><li>Cloud agnostic</li><li>Looking to improve my knowledge of a new technology</li></ul><h3>Project Structure</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/730/1*eEvDjru5qOJc73wpcie1Hw.png" /><figcaption>Project Structure</figcaption></figure><h3>Overview</h3><p>I am not a Data Engineer, nor do I actively build data pipelines; however, I recently became exposed to some data services. Combined with my general knowledge of AWS, I decided to get my hands dirty with some services.</p><p>This article stems from my experience building a data pipeline as a fun project, and I found the whole experience quite interesting and rewarding.</p><h4>Key Services used</h4><p><strong>1. Amazon S3</strong></p><ul><li>Acts as the data storage layer.</li><li>Stored the JSON files used in the project</li></ul><p><strong>2. Amazon DynamoDB</strong></p><ul><li>Serves as the database layer.</li><li>Export JSON data from DynamoDB to S3 using DynamoDB streams and AWS Lambda</li></ul><p><strong>3. AWS Glue</strong></p><ul><li>Handles Data Extraction, Transformation, and Loading (ETL).</li><li>Use Glue Data Catalog in managing metadata for datasets.</li><li>Also supports crawling using a crawler that automatically discovers schema and creates tables; however, in this project, we manually define the schema, so we didn&#39;t require this.</li></ul><p><strong>4. Amazon DataBrew</strong></p><ul><li>Used to transform the data stored in S3 by removing duplicate entries.</li><li>Runs as a triggered job(from lambda) once items are placed in the S3 bucket on the path /data</li><li>Points to Glue Data Catalog as an input dataset.</li></ul><p><strong>5. Amazon Athena</strong></p><ul><li>Query transformed data stored in Glue Catalog using standard SQL.</li><li>Fully serverless and integrated with the Glue Data Catalog.</li></ul><h3>Resulting Architecture</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/761/1*0v_rLNRcapex3MRovi_luA.png" /><figcaption>Data Pipeline Architecture</figcaption></figure><h3>Architecture Explained</h3><p><strong>First Flow:</strong></p><ul><li>A <em>.json</em> file is added to S3 on the path /data</li><li>First Glue Catalog Table reads from the /data path in S3</li><li>Upload to /data triggers a lambda function which starts the DataBrew transformation job that cleans the data in the First Glue Catalog Table(input dataset) by removing any duplicate rows using the email column</li><li>DataBrew job outputs the transformed data into a new path in S3 under the /cleaned path, overriding other items in that path to avoid duplicates in the output path.</li><li>Second Glue Catalog Table reads from the /cleaned path in S3</li><li>Athena workgroup reads from the Second Glue Catalog Table and runs queries against it. This, in turn, stores the query result in a new output location in S3 /athena-results/</li></ul><p><strong>Second</strong> <strong>flow</strong>:</p><ul><li>An item is added to DynamoDB.</li><li>DynamoDB stream is triggered as a new item is added</li><li>Lambda function(in Python) connected to the DynamoDB Stream is invoked, which converts the new items into a .<em>json</em> file</li><li>All steps from the First flow</li></ul><p>In summary, the goal of real-time data processing is achieved using DynamoDB Streams and S3 Bucket Notification integration with Lambda.</p><h3>Code Definition</h3><h4>main.tf</h4><p>Provider: The first step is usually to define the provider. Here we define the cloud provider as <em>aws.</em></p><p>Also, we include the various modules we would be using in our project and passing all required variables</p><pre>terraform {<br>  required_providers {<br>    aws = {<br>      source  = &quot;hashicorp/aws&quot;<br>      version = &quot;~&gt; 5.0&quot;<br>    }<br>  }<br>  required_version = &quot;&gt;= 1.3.0&quot;<br>}</pre><pre>provider &quot;aws&quot; {<br>  region = &quot;us-east-1&quot;<br>}</pre><pre>module &quot;s3_bucket&quot; {<br>  source = &quot;./modules/s3&quot;<br>  bucket_name = &quot;upload-bucket-data-pipeline-234&quot;<br>}</pre><pre>module &quot;dynamodb_table&quot; {<br>  source = &quot;./modules/dynamodb&quot;<br>  table_name = &quot;dynamodb-table&quot;<br>}</pre><pre>module &quot;lambda_function&quot; {<br>  source = &quot;./modules/lambda&quot;<br>  lambda_name        = &quot;dynamodb_to_s3&quot;<br>  handler_trigger     = &quot;dynamodb_to_s3_trigger.lambda_handler&quot;<br>  s3_bucket   = module.s3_bucket.bucket_name<br>  table_name  = module.dynamodb_table.table_name<br>  dynamodb_stream_arn = module.dynamodb_table.stream_arn<br>}</pre><pre>module &quot;glue_catalog&quot; {<br>  source         = &quot;./modules/glue/glue_raw&quot;<br>  database_name  = &quot;catalog_db&quot;<br>  table_name     = &quot;catalog_json_table&quot;<br>  s3_location    = &quot;s3://${module.s3_bucket.bucket_name}/data/&quot;<br>}</pre><pre>module &quot;databrew&quot; {<br>  source       = &quot;./modules/databrew&quot;<br>  glue_table   = module.glue_catalog.table_name<br>  glue_db      = module.glue_catalog.database_name<br>  s3_bucket_name    = module.s3_bucket.bucket_name<br>  data_zip     = module.lambda_function.data_zip<br>}</pre><pre>module &quot;glue_catalog_cleaned&quot; {<br>  source = &quot;./modules/glue/glue_cleaned&quot;<br>  database_name = &quot;catalog_cleaned_db&quot;<br>  table_name    = &quot;catalog_cleaned_json_table&quot;<br>  s3_location   = &quot;s3://${module.s3_bucket.bucket_name}/cleaned/&quot;<br>}</pre><pre>module &quot;athena&quot; {<br>  source = &quot;./modules/athena&quot;<br>  result_output_location = &quot;s3://${module.s3_bucket.bucket_name}/athena-results/&quot;<br>}</pre><h4>backend.tf</h4><p>First, create the S3 bucket for storing the Terraform state file remotely, which fosters collaboration.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wNoK6qiZ3af-6HkhCDH48A.png" /><figcaption>S3 bucket for Terraform state file</figcaption></figure><p>After creation, add the following content to the backend.tf file:</p><pre>terraform {<br>  backend &quot;s3&quot; {<br>    bucket = &quot;serverless-data-pipeline-backend-bucket&quot;<br>    key    = &quot;serverless-pipeline/dev/terraform.tfstate&quot;<br>    region = &quot;us-east-1&quot;<br>  }<br>}</pre><p>Once we run the terraform init and terraform apply to deploy the project, we should see the state stored in the bucket as shown below:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bJ3LTvt6vayJgGsRZsQc5A.png" /><figcaption>Terraform state file in S3</figcaption></figure><h4>[module]/main.tf</h4><p>Each service’s infrastructure and configuration are placed in its respective main.tf file</p><h4>[module]/output.tf</h4><p>Each service’s output detail is placed in its respective output.tf file</p><h4>[module]/variable.tf</h4><p>Each service&#39;s desired exported variables are placed in its respective variable.tf file</p><h3>Deploy the application</h3><p>Now we are all set to deploy our application. Run the following command to deploy.</p><p><strong>Terraform init</strong>: This initializes the project, pulling all required packages needed for deployment.</p><pre>terraform init</pre><p><strong>Terraform plan</strong>: This visualizes the proposed changes, quite useful in catching any unintended changes before deployment.</p><pre>terraform plan</pre><p><strong>Terraform apply</strong>: This deploys the project to AWS</p><pre>terraform apply</pre><h3>Testing the application</h3><p>The S3 bucket is initially loaded with a sample.json file, which has the following content:</p><pre>[{<br>  &quot;id&quot;: &quot;1&quot;,<br>  &quot;name&quot;: &quot;John Doe&quot;,<br>  &quot;email&quot;: &quot;john@example.com&quot;,<br>  &quot;timestamp&quot;: &quot;2025-04-19T12:00:00Z&quot;<br>},<br>{<br>  &quot;id&quot;: &quot;2&quot;,<br>  &quot;name&quot;: &quot;Mary Doe&quot;,<br>  &quot;email&quot;: &quot;mary@example.com&quot;,<br>  &quot;timestamp&quot;: &quot;2025-06-20T12:00:00Z&quot;<br>},<br>{<br>  &quot;id&quot;: &quot;3&quot;,<br>  &quot;name&quot;: &quot;Jane Doe&quot;,<br>  &quot;email&quot;: &quot;john@example.com&quot;,<br>  &quot;timestamp&quot;: &quot;2025-06-22T12:00:00Z&quot;<br>}<br>]</pre><p><em>N:B: As can be seen, we have a duplicate email of </em><em>john@example.com, the ideal outcome would be to remove the duplicate entry.</em></p><p>This is uploaded immediately after creating the bucket using Terraform, hence it doesn’t trigger the DataBrew job since that trigger setup happens later in the project.</p><p>To test the Real-time feature and the full flow of the project, we can either upload new objects to S3 or add new items to DynamoDB. We will be adding a new item to DynamoDB to test the full flow:</p><p>Head over to DynamoDB via the AWS console and add a new item:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Wq6-HKR3_Itogo9ncr3C-Q.png" /></figure><p>Adding this would trigger the DynamoDB stream, which uses the associated lambda function to insert a new object into S3.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HEIxp2piYd2b9y5pxqV2xA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*MGNKOjFIP0mJQ4DDgG8Agw.png" /></figure><p>Heading over to S3, we see the new item 7.json added:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/986/1*dhhBJ1oL-3OSa9JHP832oA.png" /></figure><p>Next, heading over to Data Catalog, we see the Databases and Tables</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1002/1*XFt97o-Egelq4T-PdDUQmA.png" /><figcaption>Data Catalog Databases</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*V1EV7KY8iSVvMgeGIX4Fsw.png" /><figcaption>Data Catalog table</figcaption></figure><p>Heading over to DataBrew, we see the project.</p><p>Once the lambda function is triggered by a new addition to the /data path, it starts the DataBrew transformation job:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YeJ5a_JzUjWoD8vD9-4n-A.png" /></figure><p>Once completed, it looks like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GZNvEGrYWP4Wom1VKMAwBQ.png" /><figcaption>Completed DataBrew Job</figcaption></figure><p>DataBrew Data lineage allows us to see the graphical representation of the flow with DataBrew.</p><p><strong>Idle Job</strong>:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*arMXfxrhANbnvXymY8ejBg.png" /><figcaption>Data lineage of idle job</figcaption></figure><p><strong>Running Job:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CgVAhqi82C2tZcIRpCtz6A.png" /><figcaption>Data lineage of running job</figcaption></figure><p>Lastly, head over to Athena and select the my-athena-workgroup workgroup to run queries in.</p><p>Before the DataBrew Job runs, if we run a query with Athena, there won&#39;t be any results, as the /cleaned path won&#39;t have any items yet:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8K6f2X_uNLtstmSxZu1q9g.png" /></figure><p>However, after the Databrew Job runs successfully, we click the <strong><em>Run again</em></strong> button to re-run the query in Athena:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*NDVMRuZ4Kh5GdTGIIkcHnQ.png" /></figure><p>The result below:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kjeCuYwKZMO-2RJJgpd0Dw.png" /></figure><p>If you observe, it can be seen that it removed the duplicate entry with an email john@example.com found in the sample.json file which uploaded to S3 during the project creation.</p><p>It also has the new DynamoDB item with an email mathew@gmail.com added.</p><p>This means our pipeline works as expected!!</p><h4>Adding a duplicate item via DynamoDB</h4><p>Let&#39;s test further by adding a new item to DynamoDB, but this time it would have an email matching an existing record we have.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bWxYzQotL_N0JyVhsCzpTA.png" /></figure><p>We added a new item with a conflicting email mathew@gmail.com</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*C0iTx5McyVuu5836kCQ2-Q.png" /></figure><p>This triggers a new DataBrew Job:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Rz50Z-SHv_PHcq1UUcKIgg.png" /></figure><p>Once it succeeds, we see:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*iKPaL44m6-cWhAfAW1gTJg.png" /></figure><p>Next, head over to Athena and run the query again, and this time it doesn’t have the duplicate record!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Er8Fx7_rGw4IFALgSGq6ag.png" /></figure><h3>Conclusion</h3><p>I created this project and article to improve my skills in Terraform as well and get my hands dirty in the data engineering world while sharpening my Python skills.</p><p>You can find the complete source code <a href="https://github.com/chyke007/serverless-data-pipeline">here</a></p><p>I hope this article has helped you feel more confident about building a data engineering project in AWS. I’m excited to see what you create!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=07111302ec52" width="1" height="1" alt=""><hr><p><a href="https://towardsaws.com/building-a-serverless-real-time-data-pipeline-with-terraform-aws-and-python-07111302ec52">Building a Serverless Real-Time Data Pipeline with Terraform, AWS, and Python</a> was originally published in <a href="https://towardsaws.com/">Towards AWS</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Multi-Agent Collaboration: Serverless AI Agents using AWS CDK, Bedrock, and Python]]></title>
            <link>https://towardsaws.com/multi-agent-collaboration-serverless-ai-agents-using-aws-cdk-bedrock-and-python-284a77608665?source=rss-38b4b5e55906------2</link>
            <guid isPermaLink="false">https://medium.com/p/284a77608665</guid>
            <category><![CDATA[streamlit]]></category>
            <category><![CDATA[python]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[serverless]]></category>
            <category><![CDATA[cdk]]></category>
            <dc:creator><![CDATA[Nwachukwu Chibuike]]></dc:creator>
            <pubDate>Thu, 27 Feb 2025 17:40:46 GMT</pubDate>
            <atom:updated>2025-05-08T11:05:03.261Z</atom:updated>
            <content:encoded><![CDATA[<p>In our previous 2-part <a href="https://medium.com/towards-aws/serverless-agentic-workflows-with-aws-bedrock-cdk-in-python-390c3a8fa997">article</a>, we deployed the first version of the application that connected a Bedrock Agent with a data source for an imaginary Steakhouse.</p><p>We saw how users could seamlessly interact with it to get information about menus and deals and create and delete reservations.</p><p>This article will take it further by adding more agents to the mix. These agents are designed to perform specific tasks for Steakhouse and would be managed by a Supervisor agent. We will see multi-agent collaboration in AWS and how to build a robust system that can handle multiple use cases.</p><p>Let’s get right into it!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*qib2IAD6w4UNcfTY.png" /></figure><h3>Prerequisites</h3><ul><li>AWS account</li><li>AWS CLI setup locally</li><li><a href="https://cdkworkshop.com/15-prerequisites.html">AWS CDK</a> set up.</li><li><a href="https://www.python.org/downloads/">Python3</a></li><li>Model access is enabled for the required models. Specifically, Amazon Bedrock has access to the Amazon Titan Embeddings V2 model and Anthropic Claude 3 Haiku.</li><li>Previous <a href="https://medium.com/towards-aws/serverless-agentic-workflows-with-aws-bedrock-cdk-in-python-390c3a8fa997">article</a></li></ul><h3>What would we cover</h3><ul><li>Current architecture</li><li>We review the new Steakhouse architecture.</li><li>We walk through the Python and AWS CDK code.</li><li>We perform some tests to see it in action.</li></ul><h3>Case Study: Steakhouse</h3><p>Our imaginary company <strong>Steakhouse</strong> has seen a lot of success from deploying the Bedrock agent we discussed in our last article, this convinced management to invest more into agents for various use cases.</p><p>They have also expanded and are no longer solely focused on being a steakhouse, but now have short lets they manage for customers, as well as an HR department for internal staff. They also added a ticket support team and would love to have an agent help in this department.</p><p>So Steakhouse now has 4 sub-agents: A Reservation Agent(would keep managing Steakhouse meal orders for customers), an HR Agent(would manage all staff HR issues), a Shortlet(would manage the new short let for customers), and ticket support(would manage ticket support for customers).</p><p>Then a Supervisor Agent made possible by AWS Bedrock multi-agent collaboration, would be directly connected to the client to receive all requests and coordinate with the sub-agents to give back an optimal response.</p><h3>Current Steakhouse Architecture</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/991/0*IwasEbazrqZyQd55.png" /><figcaption>Current Architecture</figcaption></figure><h3>New Steakhouse Architecture</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/883/1*ju9dAD7p8vL2IeT_CpBG5Q.png" /><figcaption>New Architecture with Supervisor</figcaption></figure><p><strong>We can see that:</strong></p><ol><li>The user visits the Streamlit Web app to interact with the Agent by visiting the URL exposed by the load balancer.</li><li>The Streamlit Web app interacts with the Supervisor agent.</li><li>The supervisor agent then decides which agent(s) to forward the request to, aggregates the response, and outputs the final relevant response to the user.</li></ol><h3>Project Overview</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/486/1*698qwl61KgdOcHbx8w5opw.png" /><figcaption>Project Overview</figcaption></figure><p>The folder structure is designed as follows:</p><ol><li>The agents_python folder contains the 2 stacks for the app, the A<strong>gent stack,</strong> and the S<strong>treamlit stack</strong>.</li><li>The streamlit folder contains the files for setting up the streamlit app via the app.py file and interacting with the bedrock agent via the agent.py helper file.</li><li>The dataset contains the files that would be added to the knowledge base and used by each agent.</li><li>The lambdas/actiongroup includes the action group lambda functions of the 4 sub-agents.</li></ol><p>We will review some parts of the newly added code.</p><h4>Creating the Supervisor Agent</h4><pre>  supervisor_agent_role = iam.Role(<br>            scope=self,<br>            id=&#39;SupervisorAgentRole&#39;,<br>            role_name=&#39;AmazonBedrockExecutionRoleForSupervisorAgent-Steakhouse&#39;,<br>            assumed_by=iam.ServicePrincipal(&#39;bedrock.amazonaws.com&#39;))<br><br>supervisor_agent_role.add_to_policy(iam.PolicyStatement(<br>            sid=&#39;SupervisorInvokeBedrockLambda&#39;,<br>            effect=iam.Effect.ALLOW,<br>            resources=[<br>                agent_model_arn],<br>            actions=[&#39;bedrock:InvokeModel&#39;, &#39;lambda:InvokeFunction&#39;]))<br>        <br>supervisor_agent_role.add_to_policy(iam.PolicyStatement(<br>            sid=&#39;SupervisorAgentInvoke&#39;,<br>            effect=iam.Effect.ALLOW,<br>            resources=[&quot;*&quot;],<br>            actions=[&#39;bedrock:*&#39;]))<br><br>  # Create supervisor agent<br>        cfn_supervisor_agent = bedrock.CfnAgent(self, &quot;SupervisorAgent&quot;,<br>                                                agent_name=supervisor_agent_name,<br>                                                agent_resource_role_arn=supervisor_agent_role.role_arn,<br>                                                auto_prepare=True,<br>                                                description=supervisor_agent_description,<br>                                                foundation_model=agent_model_id,<br>                                                instruction=supervisor_agent_instruction,<br>                                                idle_session_ttl_in_seconds=1800,<br>                                                )<br>        cfn_supervisor_agent_alias = bedrock.CfnAgentAlias(<br>            self, &quot;SupervisorAgentAlias&quot;,<br>            agent_alias_name=supervisor_agent_alias_name,<br>            agent_id=cfn_supervisor_agent.attr_agent_id)<br><br>        self.bedrock_supervisor_agent_id = cfn_supervisor_agent.attr_agent_id<br>        self.bedrock_supervisor_agent_alias_id = cfn_supervisor_agent_alias.attr_agent_alias_id</pre><p>From the above, we set up the supervisor agent with a role and required permissions.</p><p>We also create an Alias for the supervisor agent and set it up for production use. Lastly, we export the bedrock_supervisor_agent_id and bedrock_supervisor_agent_alias_id to be used by the Streamlit Stack.</p><h4>Updating app.py</h4><pre>app = cdk.App()<br>agent_stack = AgentStack(app, &quot;AgentStack&quot;)<br>streamlit_stack = StreamlitStack(app, &quot;StreamlitStack&quot;,<br>                                 bedrock_agent_id=agent_stack.bedrock_supervisor_agent_id,<br>                                 bedrock_agent_alias_id=agent_stack.bedrock_supervisor_agent_alias_id<br>                                 )<br><br>streamlit_stack.add_dependency(agent_stack)</pre><p>We update the app.py to use the supervisor agent and agent alias in the streamlit stack. This way the app would interact with the supervisor agent rather than a specific sub-agent.</p><p>A point to note: We created 4 sub-agents and assigned each an action group, with corresponding lambda function and connected them to a single Knowledge Base. However, due to the code snippet size we won&#39;t be seeing them in this article, but you can always see the full project on the repo <a href="https://github.com/chyke007/bedrock-supervisor">here</a>.</p><h4>Time to deploy 🔥 🔥</h4><p>To deploy the app:</p><pre>cdk deploy --all</pre><p>This would deploy the 2 stacks, first the Agent Python stack and then the Streamlit stack.</p><h3>Enabling Multi-agent Collaboration</h3><p><strong><em>Update</em></strong><em>: This section is no longer needed as the source code has been updated with the latest CDK version that supports multi-agent collaboration</em></p><p>Before we test the app, we need to set up the multi-agent collaboration manually using the AWS Console.</p><p>This is because AWS CDK doesn&#39;t currently support this feature, so we would carry it out via the console.</p><p>Click on the bedrock agents in the console, then select the Supervisor Agent and follow these steps:</p><p>Click the <strong>Edit in Agent Builder</strong> button:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pNfLIJ9b7D1UQvFzEbADlw.png" /></figure><p>Next, scroll down and click the <strong>Edit button</strong> in the <strong>Multi-agent collaboration feature</strong> section:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zB55_dGql3Ozio7NA8_3YQ.png" /></figure><p>Next, enable the Multi-agent collaboration feature.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OxlUv7dB8OEbxZaP4_pmxQ.png" /></figure><p>Scroll down and add the 4 sub-agent collaborators</p><ol><li>HR Agent Collaborator:</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*KtDYm6K4iVPtxQAhu8l76w.png" /><figcaption>HR Agent Collaborator</figcaption></figure><p>2. Reservation Agent Collaborator:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CWdgHM2s6memnQ9lLLiZHQ.png" /><figcaption>Reservation Agent Collaborator</figcaption></figure><p>3. Shortlet Agent Collaborator:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Uzv0EJGLUAPb6gP-gth4nA.png" /><figcaption>Shortlet Agent Collaborator:</figcaption></figure><p>4. Ticket Agent Collaborator:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4LCdMNgQ-8KAq_h7gVoDxA.png" /><figcaption>Ticket Agent Collaborator</figcaption></figure><p>Next click the “Save and exit” button at the top. This would save the changes and take us to the agent details.</p><p>Now we have 4 collaborators:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*rJVUvJNeO_RCPnKtAP-Rxw.png" /><figcaption>Supervisor Agent Collaborators</figcaption></figure><p>Click the “Save and exit” button on this page, so it saves the changes.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*o6DRHNZNXKbjOBjI8oEeWQ.png" /><figcaption>Agent details</figcaption></figure><p>Next, proceed to prepare the agent so the changes we made would take effect on the supervisor agent.</p><p>Click on the “Prepare” button</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/756/1*FS7O1WQZLVkMN7LieXbOsQ.png" /><figcaption>Prepare agent</figcaption></figure><p>Next, click on the alias and edit so we can update it to use the latest changes:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/994/1*DUPM_EUs-4v2WdxPdlzExg.png" /><figcaption>Supervisor agent alias</figcaption></figure><p>In the Edit alias modal, choose “Create a new version and associate it to this alias”. Then click the save button.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*AL31LYnGSN4xxWXBr9AY7Q.png" /><figcaption>Edit alias</figcaption></figure><p>This would create a new version and update the alias to point to this new version.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/932/1*qOPD_MYbqEnLk8ixCTMh8A.png" /></figure><p>Now the changes made are in the supervisor alias that the streamlit app is connected to.</p><h3>Testing the app</h3><p>We would test the app via the Streamlit app and the AWS console. The reason for this is the AWS console allows us to see traces, this way we can see how the supervisor agent interacts with the sub-agents when answering user queries, something we won&#39;t be able to see directly when we test using the streamlit app.</p><h4>Testing the app via AWS Console</h4><blockquote>Query 1</blockquote><p><strong>Customer</strong>: Whats the best steak I can get as well as best dessert</p><p><strong>Agent</strong>: <em>For the best steak option, I would recommend the Wagyu Ribeye. At $60 for a 12oz cut, it is a premium and flavorful steak with unmatched marbling and richness. The Tomahawk Steak is also an excellent choice for sharing.</em></p><p><em>For dessert, the Chocolate Lava Cake served with vanilla ice cream sounds like an indulgent and delicious option. The Tiramisu is another great choice for those who prefer a coffee-flavored dessert.</em></p><p><em>You may also want to consider taking advantage of the weekly steak specials like the Filet Mignon Feast on Thursdays or the discounts on T-Bones on Tuesdays</em>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/954/1*sEuesm25NNyh6J2NeMResg.png" /><figcaption>Query 1</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tVEH7hDxH8OXsXq_1fzElQ.png" /><figcaption>Trace timeline</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*erAHHj39bTqt-1P2cFdytA.png" /><figcaption>Orchestration and Knowledge Base</figcaption></figure><blockquote><em>Note: As can be seen, the Supervisor agent calls only the Reservation sub-agents which uses the Knowledge Base for context.</em></blockquote><blockquote>Query 2</blockquote><p><strong>Customer</strong>: Steakhouse Menu for Tuesday and available short lets</p><p><strong>Agent</strong>:<em> According to the information gathered: The Steakhouse menu for Tuesday features the “T-Bone Tuesday” special, where T-Bone steaks are $5 off and served with any two sides. The available short lets at the Steakhouse this week include: — Standard Room: $50 per night — Deluxe Room: $80 per night — Suite: $120 per night — Penthouse: $250 per night All short let rooms come with amenities such as free WiFi, air conditioning, 24/7 security, housekeeping services, and access to a shared lounge. Additional amenities like kitchenettes and private pools are available in select room types.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/894/1*uMn2SjwjtuPL2eS3HunkAg.png" /><figcaption>Query 2</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_EVFxo00qdInFDvlAad7Qw.png" /><figcaption>Multi-agent trace timeline</figcaption></figure><blockquote><em>Note: As can be seen, the Supervisor agent calls both sub-agents which each use the Knowledge Base for context</em></blockquote><blockquote>Query 3</blockquote><p><strong>Customer</strong>: What is the sick leave policy for me as a staff, also could you create a ticket for John with the following details: incident date: 22/03/2022 and reason: Shortlet room was untidy</p><p><strong>Agent</strong>: <em>Based on the information provided:</em></p><p><em>1. The Steakhouse sick leave policy states that all employees, including part-time staff, are eligible for sick leave. Employees can use sick leave for personal illness, medical appointments, or caring for an immediate family member. Employees must notify their manager as soon as possible if they need to take sick leave, and for absences longer than three consecutive days, a medical certificate is required.</em></p><p><em>2. I have successfully created a new ticket for John with the following details: — Incident date: 22/03/2022 — Reason: Shortlet room was untidy The new ticket has been assigned the booking ID ‘59175490’.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/904/1*QHrS7joNEUDku0GHqgsWAg.png" /><figcaption>Query 3</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*FlUDRYs4g3UG9Mv3gwKCZw.png" /><figcaption>Multi-agent trace timeline</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-FmJWmxpphEG_BOLdLrsLA.png" /><figcaption>Ticket created in DynamoDB</figcaption></figure><blockquote><em>Note: As can be seen, the Supervisor agent calls both sub-agents. It uses the Hr sub-agent Knowledge Base for context and uses the Ticket sub-agent Action group to make an api call that created the ticket in DynamoDB.</em></blockquote><blockquote>Query 4</blockquote><p><strong>Customer</strong>: Create shortlet reservations for John for 4 days on 28/02/2025 for 2 guests, for shortlet type use standard. Then recommend 1 good dessert I could take.</p><p><strong>Agent</strong>: <em>Based on the information provided, I have completed the following actions:</em></p><p><em>1. Created a new shortlet booking for John with the following details: — Booking ID: 679b0fce — Date: 28/02/2025 — Shortlet Type: standard — Number of Guests: 2 — Number of Days: 4</em></p><p><em>2. Recommended the Tiramisu or the Crème Brûlée as the best dessert options to pair with the steakhouse shortlet booking: — Tiramisu is a “perfect” pairing for Prosecco wine, which is recommended for “lighter desserts”. This would complement the indulgence of a shortlet stay. — Crème Brûlée is an elegant, classic dessert option that would pair well with a steakhouse meal. Its rich, creamy texture would make for a sophisticated end to a shortlet experience. So in summary, I would recommend either the Tiramisu or the Crème Brûlée as the best dessert option to pair with the steakhouse shortlet booking.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/910/1*LhC36jbsTjbBqmqfXsjSmQ.png" /><figcaption>Query 4</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*5QQv0mJHErO4AcbBvQ8mZQ.png" /><figcaption>Shortlet created in DynamoDB</figcaption></figure><blockquote><em>Note: As can be seen, the Supervisor agent calls the shortlet and reservation sub-agents. It uses the Shortlet sub-agent Action group to make an api call that created the shortlet reservation in DynamoDB and the Reservation sub-agent Knowledge Base for context for Dessert.</em></blockquote><h4>Testing the app via Streamlit App</h4><blockquote>Query 5</blockquote><p><strong>Customer</strong>:</p><p>Create HR booking for sick leave for Janet from 03/04/2025 to 010/04/2025, the main reason is cough, detailed comment is severe cold and doctor recommended rest. Return Shortlet details for Shortlet id: 679b0fce</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*92B1mUa_mrcSSN4rtQmKaw.png" /><figcaption>User Question</figcaption></figure><p><strong>Agent</strong>:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*An7X8FL3ierB9rtK5-R6tg.png" /><figcaption>Agent Response</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xgFDeCM1zRrgEpkUXUk_kA.png" /><figcaption>HR time off item created in DynamoDB</figcaption></figure><h3>My Observation</h3><ol><li>I was faced with Request per minute(RPM) rate throttling when I was actively testing the agent. This wasn’t a pleasant experience. I had to update my model to use Haiku 3, which gave me higher RPM, as I couldn&#39;t get the Bedrock team to increase my Claude Sonnet 3 RPM on time(I still have an open ticket for over a week now!). You may need to request an increase in service quota for Claude&#39;s quota, more info <a href="https://docs.aws.amazon.com/bedrock/latest/userguide/quotas.html">here</a>.<br>Update: I was able to use the Cross-region Inference, more details <a href="https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html">here</a>, to fix this issue</li><li>Also, the Supervisor Agent could act as a security agent where you could apply checks using guardrails.</li></ol><h3>Closing</h3><p>This has been an interesting ride. This brings an end to an exciting use case of Steakhouse, where we got to build an AI Agent using Bedrock, and extended it to support multi-agent collaboration.</p><p>You can find the complete source code <a href="https://github.com/chyke007/bedrock-supervisor">here</a>.</p><p>Excited to see what you build!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=284a77608665" width="1" height="1" alt=""><hr><p><a href="https://towardsaws.com/multi-agent-collaboration-serverless-ai-agents-using-aws-cdk-bedrock-and-python-284a77608665">Multi-Agent Collaboration: Serverless AI Agents using AWS CDK, Bedrock, and Python</a> was originally published in <a href="https://towardsaws.com/">Towards AWS</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Getting started with the AWS Multi-Agent Orchestrator Framework]]></title>
            <link>https://towardsaws.com/getting-started-with-the-aws-multi-agent-orchestrator-framework-471bdf67931b?source=rss-38b4b5e55906------2</link>
            <guid isPermaLink="false">https://medium.com/p/471bdf67931b</guid>
            <category><![CDATA[agents]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[bedrock]]></category>
            <category><![CDATA[agentic-ai]]></category>
            <category><![CDATA[python]]></category>
            <dc:creator><![CDATA[Nwachukwu Chibuike]]></dc:creator>
            <pubDate>Thu, 30 Jan 2025 13:56:21 GMT</pubDate>
            <atom:updated>2025-08-05T13:25:21.974Z</atom:updated>
            <content:encoded><![CDATA[<p>In this article, I explore the AWS Multi-Agent Orchestrator Framework using Python. We get to build multiple agents using different approaches provided by the framework to assess its capabilities and explore various use cases. It promises to be an exciting ride!</p><p><em>Update: This framework has been renamed to the AWS Agent Squad framework!</em></p><h3>Prerequisites</h3><ul><li>AWS account</li><li>AWS CLI setup locally</li><li><a href="https://www.python.org/downloads/">Python3</a></li><li>Model access is enabled for the required models.</li></ul><h3>What would we cover</h3><ul><li>We cover what Amazon <a href="https://awslabs.github.io/multi-agent-orchestrator">Multi-Agent Orchestrator</a> is.</li><li>We walk through the multiple approaches to building an Agent.</li><li>We perform some tests to see it in action.</li><li>Some observations</li></ul><h3>Introduction</h3><p>This article will utilize the AWS Multi-agent to build Multi-Agents. The goal is to get an introduction to the framework by seeing what is possible and trying out some use cases.</p><p>Let&#39;s get started!</p><h3>What is the AWS Multi-Agent Orchestrator?</h3><p>The AWS <a href="https://github.com/awslabs/multi-agent-orchestrator">Multi-Agent Orchestrator</a> is an open-source framework for building AI agents and multi-agents. It has inbuilt features such as intelligent routing, intent classification, context management, agent overlap analysis, and many more.</p><p>The Multi-Agent Orchestrator uses a <a href="https://awslabs.github.io/multi-agent-orchestrator/classifiers/overview">Classifier</a>, that does the selection of the right agent to be used for every user request.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*UyxgyaB29ut63igt.jpg" /><figcaption>High Level Diagram; Source: AWSLabs</figcaption></figure><h3>Main Components of the Orchestrator</h3><p>The main components are:</p><p><strong>Orchestrator</strong>:</p><p>The central coordinator for all other components and handles the flow of information between all other components such as Classifiers, Agents, Storage, and Retrievers.</p><p>Handles error scenarios internally and has a great fallback mechanism.</p><p><strong>Classifier</strong></p><p>Examines user input alongside the previous chat history and agent description to identify the most appropriate agent for each request. Custom classifiers could also be used.</p><p><strong>Agents</strong></p><p>From Prebuilt Agents to custom agents, this is where you create and tailor functionality for agents. You could also extend prebuilt agents as we use later in the article.</p><p><strong>Conversation Storage</strong></p><p>Used to save conversation history. Has in-built memory and DynamoDb support and can also use custom storage solutions.</p><p><strong>Retrievers</strong></p><p>Used to enhance the agent&#39;s performance by providing context and relevant information. We could attach a Knowledge Base here to augment the agent data by providing more context.</p><h3>Project Setup</h3><p>Before we start creating the agents, we need to set up our environment. head over to the terminal and run the following:</p><pre>mkdir aws-orchestrator<br>cd aws-orchestrator &amp;&amp; touch app.py<br><br>python -m venv venv<br>source venv/bin/activate </pre><p>This would set up a virtual environment for the Python app.</p><p>Next, install the multi-agent-orchestrator package:</p><pre>pip install &quot;multi-agent-orchestrator[all]&quot;</pre><h4>Using the Inbuilt Bedrock Agent with classifier approach</h4><p>First, we explore how to build an inline agent using the inbuilt Bedrock agent. We would customize it to support our use case.</p><p>Head over to app.py and input the following:</p><pre>import uuid<br>import asyncio<br>import sys<br><br>from multi_agent_orchestrator.orchestrator import MultiAgentOrchestrator, OrchestratorConfig<br>from multi_agent_orchestrator.agents import (BedrockLLMAgent,<br> BedrockLLMAgentOptions,<br> AgentResponse,<br> AgentCallbacks)<br><br>orchestrator = MultiAgentOrchestrator(options=OrchestratorConfig(<br>  LOG_AGENT_CHAT=True,<br>  LOG_CLASSIFIER_CHAT=True,<br>  LOG_CLASSIFIER_RAW_OUTPUT=True,<br>  LOG_CLASSIFIER_OUTPUT=True,<br>  LOG_EXECUTION_TIMES=True,<br>  MAX_RETRIES=3,<br>  MAX_MESSAGE_PAIRS_PER_AGENT=10,<br>  USE_DEFAULT_AGENT_IF_NONE_IDENTIFIED=True<br>))<br><br>class BedrockLLMAgentCallbacks(AgentCallbacks):<br>    def on_llm_new_token(self, token: str) -&gt; None:<br>        print(token, end=&#39;&#39;, flush=True)<br><br>football_agent = BedrockLLMAgent(BedrockLLMAgentOptions(<br>    name=&quot;Football Insights Agent&quot;,<br>    description=&quot;Expert in football analysis, covering team strategies, player stats, match predictions, and historical comparisons.&quot;,<br>    callbacks=BedrockLLMAgentCallbacks()<br>))<br>orchestrator.add_agent(football_agent)<br><br>life_hack_agent = BedrockLLMAgent(BedrockLLMAgentOptions(<br>    name=&quot;Life Hacks &amp; Motivation Agent&quot;,<br>    description=&quot;Provides life hacks for efficiency, productivity tips, motivational insights, and goal-setting strategies for self-improvement.&quot;,<br>    callbacks=BedrockLLMAgentCallbacks()<br>))<br>orchestrator.add_agent(life_hack_agent)<br><br><br>async def handle_request(_orchestrator: MultiAgentOrchestrator, _user_input: str, _user_id: str, _session_id: str):<br>    response: AgentResponse = await _orchestrator.route_request(_user_input, _user_id, _session_id)<br>    <br>    print(&quot;\nMetadata:&quot;)<br>    print(f&quot;Selected Agent: {response.metadata.agent_name}&quot;)<br>    if response.streaming:<br>        print(&#39;Response:&#39;, response.output.content[0][&#39;text&#39;])<br>    else:<br>        print(&#39;Response:&#39;, response.output.content[0][&#39;text&#39;])<br><br>if __name__ == &quot;__main__&quot;:<br>    USER_ID = &quot;user123&quot;<br>    SESSION_ID = str(uuid.uuid4())<br>    print(&quot;Welcome to the interactive Multi-Agent system. Type &#39;quit&#39; to exit.&quot;)<br>    while True:<br>        # Get user input<br>        user_input = input(&quot;\nYou: &quot;).strip()<br>        if user_input.lower() == &#39;quit&#39;:<br>            print(&quot;Exiting the program. Goodbye!&quot;)<br>            sys.exit()<br>        # Run the async function<br>        asyncio.run(handle_request(orchestrator, user_input, USER_ID, SESSION_ID))</pre><p>This creates 2 agents. A football agent and secondly a life hack agent.</p><p>Run the app: python3 app.py</p><p><strong>Input Prompt 1:</strong></p><p>Who is cristiano ronaldo</p><p><strong>Resulting output:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GZa-mvMTzvGew8YzqEavqw.png" /><figcaption>Football Insights Agent</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*73a-0VsP_pfY9AMERvc4MA.png" /><figcaption>Agent response</figcaption></figure><p>As can be seen, the <strong>Classifier</strong> routed the request to the right agent.</p><p><strong>Input Prompt 2:</strong></p><p>I need motivation to succeed in goals</p><p><strong>Resulting output:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lPULlncir3672Fx85IbHqA.png" /><figcaption>Life Hacks &amp; Motivation Agent</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4H5ka7EaCAtgp6x5f1pw3g.png" /><figcaption>Agent response</figcaption></figure><p>The <strong>Classifier</strong> also correctly routed the request to the right agent.</p><h4>Adding a Default Agent</h4><p>As can be seen, we have no Default agent and hence when we provide an input with no agent coverage it doesn&#39;t give a graceful response.</p><p><strong>Input Prompt:</strong></p><p>I need motivation to succeed in goals</p><p><strong>Resulting output:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lwSZzF2xdP9dIW4Yh73nPg.png" /></figure><p>Let&#39;s fix this by updating the code to include a Default agent. Update the orchestrator in app.py</p><pre>orchestrator = MultiAgentOrchestrator(options=OrchestratorConfig(<br>  LOG_AGENT_CHAT=True,<br>  LOG_CLASSIFIER_CHAT=True,<br>  LOG_CLASSIFIER_RAW_OUTPUT=True,<br>  LOG_CLASSIFIER_OUTPUT=True,<br>  LOG_EXECUTION_TIMES=True,<br>  MAX_RETRIES=3,<br>  MAX_MESSAGE_PAIRS_PER_AGENT=10,<br>  USE_DEFAULT_AGENT_IF_NONE_IDENTIFIED=True<br>),<br>  default_agent=BedrockLLMAgent(BedrockLLMAgentOptions(<br>    name=&quot;Default Agent&quot;,<br>    streaming=False,<br>    description=&quot;This is the default agent that handles general queries and tasks.&quot;,<br>  ))<br>)</pre><p>Rerun the app and enter the same user prompt. This time it uses the default agent to attempt the question:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HkvRJK_x8N-PsweHI9bs7A.png" /><figcaption>Selecting Default agent</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xwOWAcXQaP9sINeOIq_jog.png" /><figcaption>Default agent response</figcaption></figure><h4>Chaining Agents</h4><p>Suppose we want an ordered flow of agent collaboration, rather than the <strong>Classifier</strong> choosing the single appropriate agent.</p><p>We want some agents to work together sequentially to give an output. This is where ChainAgent comes in.</p><p>It does this by using the output of one agent as input for the next. This creates an agent <strong><em>chain</em></strong></p><p>Create a new file called chained.py and input the following:</p><pre>from multi_agent_orchestrator.agents import (BedrockLLMAgent,<br>                                             BedrockLLMAgentOptions,<br>                                             AgentCallbacks, ChainAgent, ChainAgentOptions)<br><br>class BedrockLLMAgentCallbacks(AgentCallbacks):<br>    def on_llm_new_token(self, token: str) -&gt; None:<br>        print(token, end=&#39;&#39;, flush=True)<br><br><br>research_agent = BedrockLLMAgent(BedrockLLMAgentOptions(<br>    name=&quot;Research Agent&quot;,<br>    description=&quot;Analyzes and validates the given content, expanding on relevant topics and ensuring accuracy.&quot;,<br>    callbacks=BedrockLLMAgentCallbacks(),<br>    streaming=True<br>))<br><br>analysis_agent = BedrockLLMAgent(BedrockLLMAgentOptions(<br>    name=&quot;Analysis Agent&quot;,<br>    description=&quot;Extracts key insights, trends, and relevant points from the content, identifying core themes.&quot;,<br>    callbacks=BedrockLLMAgentCallbacks(),<br>    streaming=True<br>))<br><br>report_agent = BedrockLLMAgent(BedrockLLMAgentOptions(<br>    name=&quot;Report Agent&quot;,<br>    description=&quot;Creates a structured report summarizing the research findings and key insights into a coherent format.&quot;,<br>    callbacks=BedrockLLMAgentCallbacks(),<br>    streaming=True<br>))<br><br><br>options = ChainAgentOptions(<br>    name=&#39;ChainAnalysisAgent&#39;,<br>    description=&#39;A research, analayis and report generation chain of agents that takes user research input and gives a final report after proper research &amp; analysis&#39;,<br>    agents=[research_agent, analysis_agent, report_agent],<br>    default_output=&#39;The chain processing encountered an issue.&#39;,<br>    save_chat=True<br>)<br><br>chain_agent = ChainAgent(options)</pre><p>We have 3 agents chained together. The chained agent retrieves content on any topic from the user and carries out research &amp; analysis and returns a final report.</p><h4>Using Chained Agent with Orchestrator</h4><p>Let&#39;s put this chained agent to the test. Modify the app.py file to import and add this new agent to the orchestrator:</p><pre>from chained import chain_agent<br><br># previous code<br><br>#Add Chained agent<br>orchestrator.add_agent(chain_agent)<br><br># previous code</pre><p>Now rerun the app.py file:</p><p><strong>Input Prompt:</strong></p><p>Help research on LLM. I believe its large language model and its used in Gen AI. Give me a proper research, analysis and report on this</p><p><strong>Resulting output:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Qtg1974hYMycFMH0sJyVjg.png" /><figcaption>Agent response</figcaption></figure><p>It is carrying out the needed analysis to produce the report. As can be seen, it uses the information from the research agent to generate insights and reports.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xMbEoFgx8wAX-wqu9DofsQ.png" /><figcaption>Agent response</figcaption></figure><h4>Using a supervisor agent</h4><p>The <strong>SupervisorAgent</strong> is an advanced orchestration component that enables sophisticated multi-agent coordination, also known as multi-agent collaboration. Its unique <strong>agent-as-tools</strong> architecture exposes team members to a supervisor agent as invocable tools.</p><p>Rather than sequentially, it enables parallel processing, allowing the lead agent to switch context between multiple team agents as it seems fit.</p><p>This means it could call Agent 1 first and then Agent 2 for one input, while also calling Agent 2 first and then Agent 1 for another input.</p><p>It can be used directly or with the classifier. In the example below, we use it with the orchestrator, just like we did with the chained agent.</p><p>Create a new file called surpervisor.py and input the following:</p><pre>from multi_agent_orchestrator.agents import (BedrockLLMAgent,<br>                                             BedrockLLMAgentOptions,<br>                                             AgentCallbacks,<br>                                             SupervisorAgent,<br>                                             SupervisorAgentOptions)<br><br><br>class BedrockLLMAgentCallbacks(AgentCallbacks):<br>    def on_llm_new_token(self, token: str) -&gt; None:<br>        print(token, end=&#39;&#39;, flush=True)<br><br><br>ticket_agent = BedrockLLMAgent(BedrockLLMAgentOptions(<br>            name=&quot;Ticket Agent&quot;,<br>            description=&quot;Creates a ticket about customer issues&quot;,<br>            callbacks=BedrockLLMAgentCallbacks(),<br>            streaming=True<br>        ))<br>    <br>credit_card_agent = BedrockLLMAgent(BedrockLLMAgentOptions(<br>            name=&quot;Credit Card Agent&quot;,<br>            description=&quot;Handles card issues, asks for customer card details when needed&quot;,<br>            callbacks=BedrockLLMAgentCallbacks(),<br>            streaming=True<br>        ))<br><br>supervisor_agent = SupervisorAgent(SupervisorAgentOptions(<br>    lead_agent=BedrockLLMAgent(BedrockLLMAgentOptions(<br>        name=&quot;Support Team Lead&quot;,<br>        description=&quot;Coordinates support inquiries&quot;<br>    )),<br>    team=[credit_card_agent, ticket_agent]<br>))</pre><p>We have three agents: two team members and a lead agent. The lead agent determines which agent to call or whether to use both team agents for a single user request.</p><h4>Using Supervisor Agent with Orchestrator</h4><p>Let’s put this Supervisor agent to the test. Modify the app.py file to import and add this new agent to the orchestrator:</p><pre>from supervisor import supervisor_agent<br><br>.....<br><br>#Add Supervisor agent<br>orchestrator.add_agent(supervisor_agent)</pre><p>Now rerun the app.py file:</p><p><strong>Input Prompt:</strong></p><p>My credit card has issues, create a ticket for me, and I need help resolving it</p><p><strong>Resulting output:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nJe9hl5RUDKTMbu2mfulSA.png" /><figcaption>Resulting output of Supervisor</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YigP7yNNhMjkIV6kyibNtg.png" /><figcaption>Resulting output of Supervisor</figcaption></figure><p>The lead supervisor agent could switch between its team agents based on the user&#39;s request. It combined both agents to give a useful response.</p><h3>My Observation</h3><ol><li>I haven&#39;t seen any USP(Unique Selling Point) with this framework from CrewAI. What comes to my mind is the ease of integration with other AWS services since it is an AWS framework</li><li>This is a good initiative for building agents quickly, trying out other components like custom storage, and using existing AWS services like lambda, Bedrock agent, and others.</li></ol><h3>Closing</h3><p>This has been an interesting ride. We not only got to build an AI Agent using the AWS orchestrator, but we also got to try out its multiple features/components to build AI agents.</p><p>You can find the complete source code <a href="https://github.com/chyke007/aws-orchestrator">here</a>.</p><p>Excited to see what you build!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=471bdf67931b" width="1" height="1" alt=""><hr><p><a href="https://towardsaws.com/getting-started-with-the-aws-multi-agent-orchestrator-framework-471bdf67931b">Getting started with the AWS Multi-Agent Orchestrator Framework</a> was originally published in <a href="https://towardsaws.com/">Towards AWS</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>