Is there a way to generate the AWS Console URLs for CloudWatch Log Group filters?

I had to do a similar thing to generate a back link to the logs for a lambda and did the following hackish thing to create the link:

const link = `https://${process.env.AWS_REGION}.console.aws.amazon.com/cloudwatch/home?region=${process.env.AWS_REGION}#logsV2:log-groups/log-group/${process.env.AWS_LAMBDA_LOG_GROUP_NAME.replace(/\//g, '$252F')}/log-events/${process.env.AWS_LAMBDA_LOG_STREAM_NAME.replace('$', '$2524').replace('[', '$255B').replace(']', '$255D').replace(/\//g, '$252F')}`

A colleague of mine figured out that the encoding is nothing special. It is the standard URI percent encoding but applied twice (2x). In javascript you can use the encodeURIComponent function to test this out:

let inp = 'https://console.aws.amazon.com/cloudwatch/home?region=us-east-1#logsV2:log-groups/log-group/'

console.log(encodeURIComponent(inp))
console.log(encodeURIComponent(encodeURIComponent(inp)))

This piece of javascript produces the expected output on the second encoding stage:

https%3A%2F%2Fconsole.aws.amazon.com%2Fcloudwatch%2Fhome%3Fregion%3Dus-east-1%23logsV2%3Alog-groups%2Flog-group%2F
https%253A%252F%252Fconsole.aws.amazon.com%252Fcloudwatch%252Fhome%253Fregion%253Dus-east-1%2523logsV2%253Alog-groups%252Flog-group%252F

Caution

At least some bits use the double encoding, not the whole link though. Otherwise all special characters would occupy 4 characters after double encoding, but some still occupy only 2 characters. Hope this helps anyway ;)


First of all I'd like to thank other guys for the clues. Further goes the complete explanation how Log Insights links are constructed.

Overall it's just weirdly encoded conjunction of an object structure that works like that:

  • Part after ?queryDetail= is object representation and {} are represented by ~()

  • Object is walked down to primitive values and the latter are transformed as following:

    • encodeURIComponent(value) so that all special characters are transformed to %xx
    • replace(/%/g, "*") so that this encoding is not affected by top level ones
    • if value type is string - it is prefixed with unmatched single quote

    To illustrate:

    "Hello world" -> "Hello%20world" -> "Hello*20world" -> "'Hello*20world"
    
  • Arrays of transformed primitives are joined using ~ and as well put inside ~() construct

Then, after primitives transformation is done - object is joined using "~".

After that string is escape()d (note that not encodeURIComponent() is called as it doesn't transform ~ in JS).

After that ?queryDetail= is added.

And finally this string us encodeURIComponent()ed and as a cherry on top - % is replaced with $.

Let's see how it works in practice. Say these are our query parameters:

const expression = `fields @timestamp, @message
    | filter @message not like 'example'
    | sort @timestamp asc
    | limit 100`;

const logGroups = ["/application/sample1", "/application/sample2"];

const queryParameters = {
  end: 0,
  start: -3600,
  timeType: "RELATIVE",
  unit: "seconds",
  editorString: expression,
  isLiveTrail: false,
  source: logGroups,
};

Firstly primitives are transformed:

const expression = "'fields*20*40timestamp*2C*20*40message*0A*20*20*20*20*7C*20filter*20*40message*20not*20like*20'example'*0A*20*20*20*20*7C*20sort*20*40timestamp*20asc*0A*20*20*20*20*7C*20limit*20100";

const logGroups = ["'*2Fapplication*2Fsample1", "'*2Fapplication*2Fsample2"];

const queryParameters = {
  end: 0,
  start: -3600,
  timeType: "'RELATIVE",
  unit: "'seconds",
  editorString: expression,
  isLiveTrail: false,
  source: logGroups,
};

Then, object is joined using ~ so we have object representation string:

const objectString = "~(end~0~start~-3600~timeType~'RELATIVE~unit~'seconds~editorString~'fields*20*40timestamp*2C*20*40message*0A*20*20*20*20*7C*20filter*20*40message*20not*20like*20'example'*0A*20*20*20*20*7C*20sort*20*40timestamp*20asc*0A*20*20*20*20*7C*20limit*20100~isLiveTrail~false~source~(~'*2Fapplication*2Fsample1~'*2Fapplication*2Fsample2))"

Now we escape() it:

const escapedObject = "%7E%28end%7E0%7Estart%7E-3600%7EtimeType%7E%27RELATIVE%7Eunit%7E%27seconds%7EeditorString%7E%27fields*20*40timestamp*2C*20*40message*0A*20*20*20*20*7C*20filter*20*40message*20not*20like*20%27example%27*0A*20*20*20*20*7C*20sort*20*40timestamp*20asc*0A*20*20*20*20*7C*20limit*20100%7EisLiveTrail%7Efalse%7Esource%7E%28%7E%27*2Fapplication*2Fsample1%7E%27*2Fapplication*2Fsample2%29%29"

Now we append ?queryDetail= prefix:

const withQueryDetail = "?queryDetail=%7E%28end%7E0%7Estart%7E-3600%7EtimeType%7E%27RELATIVE%7Eunit%7E%27seconds%7EeditorString%7E%27fields*20*40timestamp*2C*20*40message*0A*20*20*20*20*7C*20filter*20*40message*20not*20like*20%27example%27*0A*20*20*20*20*7C*20sort*20*40timestamp*20asc*0A*20*20*20*20*7C*20limit*20100%7EisLiveTrail%7Efalse%7Esource%7E%28%7E%27*2Fapplication*2Fsample1%7E%27*2Fapplication*2Fsample2%29%29"

Finally we URLencode it and replace % with $ and vois la:

const result = "$3FqueryDetail$3D$257E$2528end$257E0$257Estart$257E-3600$257EtimeType$257E$2527RELATIVE$257Eunit$257E$2527seconds$257EeditorString$257E$2527fields*20*40timestamp*2C*20*40message*0A*20*20*20*20*7C*20filter*20*40message*20not*20like*20$2527example$2527*0A*20*20*20*20*7C*20sort*20*40timestamp*20asc*0A*20*20*20*20*7C*20limit*20100$257EisLiveTrail$257Efalse$257Esource$257E$2528$257E$2527*2Fapplication*2Fsample1$257E$2527*2Fapplication*2Fsample2$2529$2529"

And putting it all together:

function getInsightsUrl(queryDefinitionId, start, end, expression, sourceGroup, timeType = 'ABSOLUTE', region = 'eu-west-1') {
  const p = m => escape(m);
  const s = m => escape(m).replace(/%/gi, '*');

  const queryDetail
    = p('~(')
      + p("end~'")
      + s(end.toUTC().toISO()) // converted using Luxon
      + p("~start~'")
      + s(start.toUTC().toISO()) // converted using Luxon
      // Or use UTC instead of Local
      + p(`~timeType~'${timeType}~tz~'Local~editorString~'`)
      + s(expression)
      + p('~isLiveTail~false~queryId~\'')
      + s(queryDefinitionId)
      + p("~source~(~'") + s(sourceGroup) + p(')')
    + p(')');

  return `https://${region}.console.aws.amazon.com/cloudwatch/home?region=${region}#logsV2:logs-insights${escape(`?queryDetail=${queryDetail}`).replace(/%/gi, '$')}`;
}

Of course reverse operation can be performed as well.

That's all folks. Have fun, take care and try to avoid doing such a weird stuff yourselves. :)