有没有办法为 CloudWatch 日志组过滤器生成 AWS 控制台 URL?

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

我想将我的用户直接发送到特定的日志组并进行过滤,但我需要能够生成正确的 URL 格式。比如这个URL

https://console.aws.amazon.com/cloudwatch/home?region=us-east-1#logsV2:log-groups/log-group/
%252Fmy%252Flog%252Fgroup%252Fgoes%252Fhere/log-events/FfilterPatternD5Bincoming_ip2CBuser_name2CBuser_ipB2CBtimestamp2CBrequestB213DB22GETB2Fhealth_checks2FallB*222CBstatus_codeB3DB5*B7C7CBstatus_codeB3DB4292CBbytes2CBurl2CBuser_agent5DstartD-172800000

将带您进入名为 /my/log/group/goes/here 的日志组,并过滤​​过去 2 天内使用此模式的消息:

[incoming_ip, user_name, user_ip , timestamp, request != "GET /health_checks/all *", status_code = 5* || status_code = 429, bytes, url, user_agent]

我可以解码部分 URL 但我不知道其他一些字符应该是什么(见下文),但这看起来不像任何标准 HTML编码给我。有人知道这种 URL 格式的 encoder/decoder 吗?

%252F == /
2C == ,
5B == [
5D == ]
3D == =
21 == !
22 == "
2F == _
7C == |

B == +
 == &
D == =
F == ?

我创建了一些似乎满足 CloudWatch URL 解析器的 Ruby 代码。我不确定为什么您必须双重转义某些内容,然后将 % 替换为 $ 其他内容。我猜这背后有一些原因,但我想不出一个好的方法来做到这一点,所以我只是蛮力强迫它。如果你有更好的东西,或者知道他们为什么这样做,请添加评论。

注意:我测试过的 filter 有点基础,我不确定如果您真的喜欢它可能需要更改什么。

# Basic URL that is the same across all requests
url = 'https://console.aws.amazon.com/cloudwatch/home?region=us-east-1#logsV2:log-groups/log-group/'

# CloudWatch log group
log_group = '/aws/my/log/group'

# Either specify the instance you want to search or leave it out to search all instances
instance = '/log-events/i-xxxxxxxxxxxx'
 OR
instance = '/log-events'

# The filter to apply.
filter = '[incoming_ip, user_name, user_ip , timestamp, request, status_code = 5*, bytes, url, user_agent]'

# Start time.  There might be an End time as well but my queries haven't used 
# that yet so I'm not sure how it's formatted.  It should be pretty similar
# though.
hours = 48
start = "&start=-#{hours*60*60*1000}"

# This will get you the final URL
final = url + CGI.escape(CGI.escape(log_group)) + instance + 'FfilterPatternD' + CGI.escape(CGI.escape(filter)).gsub('%','$') + CGI.escape(start).gsub('%','$')

我不得不做类似的事情来为 lambda 生成返回 link 的日志,并做了以下骇人听闻的事情来创建 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, '2F')}/log-events/${process.env.AWS_LAMBDA_LOG_STREAM_NAME.replace('$', '24').replace('[', '5B').replace(']', '5D').replace(/\//g, '2F')}`

我的一个同事发现编码没什么特别的。 它是标准 URI percent encoding 但应用了两次 (2x)。在 javascript 中,您可以使用 encodeURIComponent 函数进行测试:

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)))

这段 javascript 在第二个编码阶段产生了预期的输出:

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

注意

至少有些位使用双重编码,但不是全部 link。否则所有特殊字符在双重编码后都会占用4个字符,但有些仍然只占用2个字符。希望这有帮助 ;)

我的完整 Javascript 解决方案基于 @isaias-b 的回答,它还在日志上添加了时间戳过滤器:

const logBaseUrl = 'https://console.aws.amazon.com/cloudwatch/home?region=us-east-1#logsV2:log-groups/log-group';
const encode = text => encodeURIComponent(text).replace(/%/g, '$');
const awsEncode = text => encodeURIComponent(encodeURIComponent(text)).replace(/%/g, '$');
const encodeTimestamp = timestamp => encode('?start=') + awsEncode(new Date(timestamp).toJSON());
const awsLambdaLogBaseUrl = `${logBaseUrl}/${awsEncode('/aws/lambda/')}`;
const logStreamUrl = (logGroup, logStream, timestamp) =>
  `${awsLambdaLogBaseUrl}${logGroup}/log-events/${awsEncode(logStream)}${timestamp ? encodeTimestamp(timestamp) : ''}`;

基于@Pål Brattberg 的回答的 Python 解决方案:

cloudwatch_log_template = "https://{AWS_REGION}.console.aws.amazon.com/cloudwatch/home?region={AWS_REGION}#logsV2:log-groups/log-group/{LOG_GROUP_NAME}/log-events/{LOG_STREAM_NAME}"
log_url = cloudwatch_log_template.format(
    AWS_REGION=AWS_REGION, LOG_GROUP_NAME=CLOUDWATCH_LOG_GROUP, LOG_STREAM_NAME=LOG_STREAM_NAME
)

如果您使用任何非法字符,请确保先替换非法字符(参见 OP)。

有点晚了,但这里有一个 python 实现

def get_cloud_watch_search_url(search, log_group, log_stream, region=None,):
    """Return a properly formatted url string for search cloud watch logs

    search = "{$.message: "You are amazing"}
    log_group = Is the group of message you want to search
    log_stream = The stream of logs to search
    """

    url = f'https://{region}.console.aws.amazon.com/cloudwatch/home?region={region}'

    def aws_encode(value):
        """The heart of this is that AWS likes to quote things twice with some substitution"""
        value = urllib.parse.quote_plus(value)
        value = re.sub(r"\+", " ", value)
        return re.sub(r"%", "$", urllib.parse.quote_plus(value))

    bookmark = '#logsV2:log-groups'
    bookmark += '/log-group/' + aws_encode(log_group)
    bookmark += "/log-events/" + log_stream
    bookmark += re.sub(r"%", "$", urllib.parse.quote("?filterPattern="))
    bookmark += aws_encode(search)

    return url + bookmark

这样您就可以快速验证它。

>>> real = 'https://us-west-2.console.aws.amazon.com/cloudwatch/home?region=us-west-2#logsV2:log-groups/log-group/2Fapp2Fdjango/log-events/productionFfilterPatternD7B24.msg3D2225s25s+messages+to+25s+pk3D25d...227D'
>>> constructed = get_cloud_watch_search_url(None, search='{$.msg="%s%s messages to %s pk=%d..."}', log_group='/app/django', log_stream='production', region='us-west-2')
>>> real == constructed
True

我最近在想生成 cloudwatch insights 时遇到了这个问题URL。以下打字稿版本:

export function getInsightsUrl(
  start: Date,
  end: Date,
  query: string,
  sourceGroup: string,
  region = "us-east-1"
) {
  const p = (m: string) => escape(m);

  // encodes inner values
  const s = (m: string) => escape(m).replace(/\%/gi, "*");

  const queryDetail =
    p(`~(end~'`) +
    s(end.toISOString()) +
    p(`~start~'`) +
    s(start.toISOString()) +
    p(`~timeType~'ABSOLUTE~tz~'UTC~editorString~'`) +
    s(query) +
    p(`~isLiveTail~false~queryId~'`) +
    s(v4()) +
    p(`~source~(~'`) +
    s(sourceGroup) +
    p(`))`);

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

Github GIST

我最近在想要生成 cloudwatch 见解时遇到了这个问题 URL。 PHP 以下版本:

  <?php
  function getInsightsUrl($region = 'ap-northeast-1') {

    // 
    $start = now()->subMinutes(2)->format('Y-m-d\TH:i:s.v\Z');
    $end = now()->addMinutes(2)->format('Y-m-d\TH:i:s.v\Z');

    $filter = 'INFO';

    $logStream = 'xxx_backend_web';
    $sourceGroup = '/ecs/xxx_backend_prod';
    // $sourceGroup = '/aws/ecs/xxx_backend~\'/ecs/xxx_backend_dev'; // multiple source group

    $query =
        "fields @timestamp, @message \n" .
        "| sort @timestamp desc\n" .
        "| filter @logStream like '$logStream'\n" .
        "| filter @message like '$filter'\n" .
        "| limit 20";

    $queryDetail = urlencode(
        ("~(end~'") .
        ($end) .
        ("~start~'") .
        ($start) .
        ("~timeType~'ABSOLUTE~tz~'Local~editorString~'") .
        ($query) .
        ("~isLiveTail~false~queryId~'") .
        ("~source~(~'") .
        ($sourceGroup) .
        ("))")
    );

    $queryDetail = preg_replace('/\%/', '$', urlencode("?queryDetail=" . $queryDetail));

    return
        "https://console.aws.amazon.com/cloudwatch/home?region=${region}#logsV2:logs-insights"
        . $queryDetail;
}

首先我要感谢其他人提供的线索。进一步完整解释了如何构建 Log Insights 链接。

总的来说,它只是一个对象结构的奇怪编码连接,其工作方式如下:

  • ?queryDetail=之后的部分是对象表示,{}表示为~()

  • 对象被向下移动到原始值,后者转换如下:

    • encodeURIComponent(value) 以便所有特殊字符都转换为 %xx
    • replace(/%/g, "*") 以便此编码不受顶级编码的影响
    • 如果值类型是 string - 它是 前缀 不匹配的单引号

    举例说明:

    "Hello world" -> "Hello%20world" -> "Hello*20world" -> "'Hello*20world"
    
  • 使用 ~ 加入转换后的基元数组,并放入 ~() 构造

然后,在基元转换完成后 - 使用“~”连接对象。

在该字符串之后是 escape()d(请注意,不是 encodeURIComponent() 被调用,因为它不会在 JS 中转换 ~)。

之后添加?queryDetail=

最后这个字符串我们 encodeURIComponent()ed 并作为顶部的樱桃 - % 替换为 $

让我们看看它在实践中是如何工作的。假设这些是我们的查询参数:

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,
};

首先转换图元:

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,
};

然后,使用 ~ 连接对象,所以我们有对象表示字符串:

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))"

现在我们escape()它:

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"

现在我们附加 ?queryDetail= 前缀:

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"

最后我们对其进行 URLencode 并将 % 替换为 $ 和 vois la:

const result = "FqueryDetailD7E28end7E07Estart7E-36007EtimeType7E27RELATIVE7Eunit7E27seconds7EeditorString7E27fields*20*40timestamp*2C*20*40message*0A*20*20*20*20*7C*20filter*20*40message*20not*20like*2027example27*0A*20*20*20*20*7C*20sort*20*40timestamp*20asc*0A*20*20*20*20*7C*20limit*201007EisLiveTrail7Efalse7Esource7E287E27*2Fapplication*2Fsample17E27*2Fapplication*2Fsample22929"

当然也可以反向操作

这就是所有人。玩得开心,保重并尽量避免自己做这种奇怪的事情。 :)

由于 Python 贡献与日志组相关,而不与日志洞察力相关,因此这是我的贡献。我想我可以用内部函数做得更好,但这是一个很好的起点:

from datetime import datetime, timedelta
import re
from urllib.parse import quote
 
def get_aws_cloudwatch_log_insights(query_parameters, aws_region):
    def quote_string(input_str):
        return f"""{quote(input_str, safe="~()'*").replace('%', '*')}"""


    def quote_list(input_list):
        quoted_list = ""
        for item in input_list:
            if isinstance(item, str):
                item = f"'{item}"

            quoted_list += f"~{item}"
        return f"({quoted_list})"


    params = []
    for key, value in query_parameters.items():
        if key == "editorString":
            value = "'" + quote(value)
            value = value.replace('%', '*')
        elif isinstance(value, str):
            value = "'" + value
        if isinstance(value, bool):
            value = str(value).lower()
        elif isinstance(value, list):
            value = quote_list(value)
        params += [key, str(value)]

    object_string = quote_string("~(" + "~".join(params) + ")")
    scaped_object = quote(object_string, safe="*").replace("~", "%7E")
    with_query_detail = "?queryDetail=" + scaped_object
    result = quote(with_query_detail, safe="*").replace("%", "$")

    final_url = f"https://{aws_region}.console.aws.amazon.com/cloudwatch/home?region={aws_region}#logsV2:logs-insights{result}"

    return final_url

示例:

aws_region = "eu-west-1"

query = """fields @timestamp, @message
| filter @message not like 'example'
| sort @timestamp asc
| limit 100"""

log_groups = ["/application/sample1", "/application/sample2"]

query_parameters = {
  "end": datetime.utcnow().isoformat(timespec='milliseconds') + "Z",
  "start": (datetime.utcnow() - timedelta(days=2)).isoformat(timespec='milliseconds') + "Z",
  "timeType": "ABSOLUTE",
  "unit": "seconds",
  "editorString": query,
  "isLiveTrail": False,
  "source": log_groups,
}

print(get_aws_cloudwatch_log_insights(query_parameters, aws_region))