aws-observability / aws-rum-web

Amazon CloudWatch RUM Web Client
Apache License 2.0
118 stars 64 forks source link

Support AWS Cfn/CDK output of AppMonitor properties/code snippet #70

Closed ckifer closed 2 years ago

ckifer commented 2 years ago

https://github.com/aws/aws-cdk/issues/17939 adds support for the AppMonitor Cfn resource within CDK. Is there any way to use that or will there be any future way to output the CW RUM JS snippet (or the properties needed for the snippet) to inject into webapps during deployments?

qhanam commented 2 years ago

The CloudWatch RUM console currently generates the snippet independently of the service.

I think the only variable needed from RUM to build a snippet is the AppMonitor ID. It looks like CloudFormation outputs the name of the AppMonitor only, and does not output the AppMonitor ID.

We will need to add the AppMonitor ID as output of AWS::RUM::AppMonitor. After this is done, I think a snippet could be manually constructed in CloudFormation in a similar manner to how the RUM console does it.

ckifer commented 2 years ago

Sweet, I came to this understanding as well and implemented it with an AWS SDK call in a custom resource like this in my CDK:

  private getAppMonitorId() {
    const awsRUMSDKCall: AwsSdkCall = {
      service: 'RUM',
      action: 'getAppMonitor',
      parameters: { Name: this.rumMonitor.name },
      physicalResourceId: PhysicalResourceId.of(
        'xxx'
      ),
    };

    const customResource = new AwsCustomResource(
      this,
      'Custom::GetAppMonitorId',
      {
        policy: AwsCustomResourcePolicy.fromSdkCalls({
          resources: [this.rumMonitorArn],
        }),
        installLatestAwsSdk: true,
        onCreate: awsRUMSDKCall,
        onUpdate: awsRUMSDKCall,
        functionName: 'GetAppMonitorCustomResourceHandler',
      }
    );

    customResource.node.addDependency(this.rumMonitor);

    return customResource.getResponseField('AppMonitor.Id');
  }

It would be awesome to have AppMonitor ID by default in AWS::RUM::AppMonitor though!

qhanam commented 2 years ago

Aha, thanks for sharing this!

I checked and adding the AppMonitor ID as a CloudFormation output is in our backlog. I am closing this issue here. I've referenced it in the internal issue tracking for the relevant components.

a-h commented 2 years ago

Hi @qhanam, I see you've closed this issue because you're tracking it internally within something, but obviously, I can't see that, and I have no way of being notified when you've added the feature.

It would be great if you left issues like this open until you've implemented it.

In Github you can subscribe to issues, to get notified when there are updates.

So ideally, when you've added this reference, someone in your team would add a link to the documentation and close this issue, and everyone who subscribed would get a notification that it's done.

My goal is to export the AppMonitor ID as a CloudFormation export from CDK, because it's required to bundle the client-side application, but I haven't been able to work out how to use the snippet provided by @ckifer to do that.

ckifer commented 2 years ago

Hi @a-h!

Totally agree with the value of leaving issues like this open. It can be tricky to track things two places at once though so I understand why things get closed. If they don't re-open this I'd be glad to comment back here once I know that feature is out :).

In the meantime, what issues are you having with the above snippet? It's still working like a charm for me (CDK 2.17 or so).

a-h commented 2 years ago

Thanks @ckifer, I wasn't sure where to add the function, or where the value of the physical resource ID would come from.

I used the L1 construct detailed here in https://github.com/aws/aws-cdk/issues/17939#issuecomment-997152155

export interface RumProps {
    domainName: string;
    siteSubDomain: string;
}

export class Rum extends Construct {
    constructor(parent: Construct, name: string, props: RumProps) {
        super(parent, name);

        const siteDomainName = `${props.siteSubDomain}.${props.domainName}`;

        // The identity pool and role are usually created automatically by the UI-based wizard.
        // I generated one via the wizard to determine the proper settings.
        // See also: https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-RUM-get-started-authorization.html
        const identityPool = new cognito.CfnIdentityPool(this, 'RumAppIdentityPool', {
            identityPoolName: `${siteDomainName}-rum-monitor`,
            allowUnauthenticatedIdentities: true,
        });

// ... etc

I just wasn't sure where the function would get added to that construct in order for me to export a CloudFormation CfnExport containing the App Monitor ID, which I can then feed into the build step of my application.

ckifer commented 2 years ago

@a-h Sweet yeah so you'd just add the snippet to the construct class. For the physicalId you can use anything as long as it doesn't change. I generated a random UUID and used it hardcoded.

This is my full construct:

export type Telemetries = 'errors' | 'performance' | 'http';

export interface CloudwatchRUMProps {
  stage: StageName;
  monitorDomain: string;
  telemetries?: Telemetries[];
}

export class CloudwatchRUM extends Construct {
  public unauthenticatedRoleArn: string;
  public readonly rumMonitor: CfnAppMonitor;
  public readonly identityPoolId: string;
  public readonly appMonitorId: string;
  private rumMonitorArn: string;

  constructor(scope: Construct, id: string, env: DeploymentEnvironment, props: CloudwatchRUMProps) {
    super(scope, id);

    this.rumMonitorArn = `arn:aws:rum:${env.region}:${env.account}:appmonitor/${id}`;
    this.identityPoolId = this.createIdentityPool(id);
    this.rumMonitor = this.createRUMMonitor(id, props);
    this.appMonitorId = this.getAppMonitorId();
  }

  private createRUMMonitor(id: string, props: CloudwatchRUMProps): CfnAppMonitor {
    const { stage, monitorDomain, telemetries } = props;
    const monitor = new CfnAppMonitor(this, id, {
      name: id,
      appMonitorConfiguration: {
        allowCookies: true,
        enableXRay: true,
        sessionSampleRate: stage === 'prod' ? 1 : 0.5,
        telemetries: telemetries || ['errors', 'performance', 'http'],
        guestRoleArn: this.unauthenticatedRoleArn,
        identityPoolId: this.identityPoolId,
      },
      cwLogEnabled: true,
      domain: monitorDomain,
    });
    return monitor;
  }

  private createIdentityPool(id: string) {
    const identityPool = new CfnIdentityPool(this, `${id}-IdentityPool`, {
      identityPoolName: `${id}-IdentityPool`,
      allowUnauthenticatedIdentities: true,
    });
    const identityPoolId = identityPool.ref;

    /**
     * Role associated with RUM users.
     * RUM can only accept requests from our domain and the only permissions this role gives is rum:PutRumEvents
     */
    const unauthenticatedRole = new Role(this, `${id}-RUMCognitoUnauthenticatedRole`, {
      assumedBy: new FederatedPrincipal(
        'cognito-identity.amazonaws.com',
        {
          StringEquals: {
            'cognito-identity.amazonaws.com:aud': identityPoolId,
          },
          'ForAnyValue:StringLike': {
            'cognito-identity.amazonaws.com:amr': 'unauthenticated',
          },
        },
        'sts:AssumeRoleWithWebIdentity'
      ),
      inlinePolicies: {
        PutRumEvents: new PolicyDocument({
          statements: [
            new PolicyStatement({
              effect: Effect.ALLOW,
              actions: ['rum:PutRumEvents'],
              resources: [this.rumMonitorArn],
            }),
          ],
        }),
      },
    });
    this.unauthenticatedRoleArn = unauthenticatedRole.roleArn;

    const roleAttachment = new CfnIdentityPoolRoleAttachment(this, `${id}-IdentityPoolRoleAttachment`, {
      identityPoolId: identityPoolId,
      roles: {
        unauthenticated: unauthenticatedRole.roleArn,
      },
    });
    roleAttachment.node.addDependency(identityPool);
    return identityPoolId;
  }

  private getAppMonitorId() {
    const awsRUMSDKCall: AwsSdkCall = {
      service: 'RUM',
      action: 'getAppMonitor',
      parameters: { Name: this.rumMonitor.name },
      // this can be anything, as long as it stays the same. I just generated a UUID.
      physicalResourceId: PhysicalResourceId.of('xxx-xxx'),
    };

    const customResource = new AwsCustomResource(this, 'Custom::GetAppMonitorId', {
      policy: AwsCustomResourcePolicy.fromSdkCalls({
        resources: [this.rumMonitorArn],
      }),
      installLatestAwsSdk: true,
      onCreate: awsRUMSDKCall,
      onUpdate: awsRUMSDKCall,
    });

    customResource.node.addDependency(this.rumMonitor);

    return customResource.getResponseField('AppMonitor.Id');
  }
}

From here you can access the appMonitorId on the construct

const rum = new CloudwatchRUM({ ...props  });
new CfnOutput(this, 'RUMAppMonitorId', { value: rum.appMonitorId });
BulletBored commented 2 years ago

@ckifer Hi, when I try to read rum.appMonitorId with above code I still get ${Token[TOKEN.4086]} is it what is expected or should it be reading an actual MonitorId? Thanks!

ckifer commented 2 years ago

@BulletBored that is a token that will resolve to your monitor ID at deploy time. It doesn't know the AppMonitorId until the app monitor is actually created so that value will be a token until the monitor finishes deploying and the custom resource can retrieve the ID.

BulletBored commented 2 years ago

Ah, I see, that makes sense now, thanks! Appreciate the prompt response!

@BulletBored that is a token that will resolve to your monitor ID at deploy time. It doesn't know the AppMonitorId until the app monitor is actually created so that value will be a token until the monitor finishes deploying and the custom resource can retrieve the ID.

dastra commented 1 year ago

Similar to CDK workaround above, I've put together a customer resource in a cloudformation template which extracts both the RUM App Monitor ID and Cognito Guest Role Arn which allow you to construct the Javascript snippet:

---
AWSTemplateFormatVersion: 2010-09-09

Description: >
    Deploys a Cloudwatch RUM App Monitor 

Parameters:
  CognitoIdentityPoolId:
    Type: String
    Description: ID of the Amazon Cognito identity pool that is used to authorize the sending of data to CloudWatch RUM.
  ApplicationDomain:
    Type: String
    Description: The domain that the application is hosted on - for instance xxxxxxxxx.cloudfront.net
  AppMonitorDesc:
    Type: String
    Description: The desired name of the App Monitor stack

Resources:

  # Create the App Monitor
  RumAppMonitor:
    Type: 'AWS::RUM::AppMonitor'
    Properties:
      AppMonitorConfiguration:
        AllowCookies: true
        EnableXRay: true
        IdentityPoolId: !Ref CognitoIdentityPoolId
        SessionSampleRate: 1
        Telemetries:
          - 'performance'
          - 'errors'
          - 'http'
      CwLogEnabled: true
      Domain: !Ref ApplicationDomain
      Name: !Ref AppMonitorDesc

  # The custom resource allows Cloudformation to call the Lambda function which fetches the RumAppMonitorID and CognitoGuestRoleArn
  FetchAppMonitorId:
    Type: Custom::FetchAppMonitorId
    Properties:
      ServiceToken: !GetAtt FetchAppMonitorIdFunction.Arn
      AppMonitorName: !Ref RumAppMonitor

  # The RUM AppMonitorID and CognitoGuestRoleArn are not returned by Cloudformation when creating the App Monitor.
  # The lambda function below fetches both after creation to allow them to be used to create the Javascript snippet
  FetchAppMonitorIdFunction:
    Type: "AWS::Lambda::Function"
    Properties:
      Description: 'Deployment utility function that fetches the Cloudwatch RUM AppMonitor ID'
      Role: !GetAtt FetchAppMonitorIdLambdaExecutionRole.Arn
      Code:
        ZipFile: |
          import boto3
          import cfnresponse

          def handler(event, context):
              response_data = {}
              response_status = cfnresponse.FAILED

              if event['RequestType'] == 'Delete':
                  response_data['AppMonitorId'] = ''
                  response_data['CognitoGuestRoleArn'] = ''
                  response_data['Message'] = "Delete OK"
                  response_status = cfnresponse.SUCCESS
              else:
                try:
                    rum = boto3.client('rum')
                    app_monitor_name = event['ResourceProperties']['AppMonitorName']

                    app_monitor_details = rum.get_app_monitor(
                        Name=app_monitor_name
                    )

                    app_monitor_id = app_monitor_details['AppMonitor']['Id']
                    identity_pool_id = app_monitor_details['AppMonitor']['AppMonitorConfiguration']['IdentityPoolId']

                    response_data['Message'] = "App Monitor ID Fetched OK"

                    cognito = boto3.client('cognito-identity')
                    identity_pool_roles = cognito.get_identity_pool_roles(
                        IdentityPoolId=identity_pool_id
                    )
                    cognito_guest_role_arn = identity_pool_roles['Roles']['unauthenticated']

                    response_data['AppMonitorId'] = app_monitor_id
                    response_data['CognitoGuestRoleArn'] = cognito_guest_role_arn

                    response_data['Message'] = "All information fetched OK"
                    response_status = cfnresponse.SUCCESS
                except Exception as e:
                    print("Error: " + str(e))
                    response_data['Message'] = "Resource {} failed: {}".format(event['RequestType'], e)

              cfnresponse.send(event, context, response_status, response_data)
      Handler: index.handler
      Runtime: python3.9
      Timeout: 30

  FetchAppMonitorIdLambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action:
              - 'sts:AssumeRole'
      Policies:
        - PolicyName: LoggingPolicy
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: '*'
        - PolicyName: RumPolicy
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - rum:GetAppMonitor
                Resource:
                  - '*'
        - PolicyName: CognitoIdentityPoolsPolicy
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - cognito-identity:GetIdentityPoolRoles
                Resource:
                  - '*'

Outputs:

  RumAppMonitorId:
    Description: The id of the RUM App Monitor
    Value: !GetAtt FetchAppMonitorId.AppMonitorId

  CognitoGuestRoleArn:
    Description: The ARN of the guest Cognito role used by Cloudwatch RUM
    Value: !GetAtt FetchAppMonitorId.CognitoGuestRoleArn
haydster7 commented 1 year ago

For anyone still looking for this, can't seem to find much on the open issues You can now get the app monitor ID from attrid. CDK doco of the property

const monitor = new CfnAppMonitor(this, id, {...props});
const appMonitorId = monitor.attrId;
ckifer commented 1 year ago

@haydster7 thank you!! Time to remove some custom resources