mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-11 23:40:48 +01:00
Compare commits
1 Commits
issue_5602
...
feat/monac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c8ce36b4c |
@@ -87,6 +87,7 @@
|
||||
"jest": "30.2.0",
|
||||
"js-base64": "^3.7.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"monaco-editor": "0.55.1",
|
||||
"motion": "12.4.13",
|
||||
"nuqs": "2.8.8",
|
||||
"overlayscrollbars": "^2.16.0",
|
||||
@@ -235,4 +236,4 @@
|
||||
"tmp@<0.2.6": ">=0.2.6 <0.3.0",
|
||||
"yaml@>=1.0.0 <1.10.3": ">=1.10.3 <2"
|
||||
}
|
||||
}
|
||||
}
|
||||
57
frontend/pnpm-lock.yaml
generated
57
frontend/pnpm-lock.yaml
generated
@@ -180,6 +180,9 @@ importers:
|
||||
lodash-es:
|
||||
specifier: ^4.17.21
|
||||
version: 4.18.1
|
||||
monaco-editor:
|
||||
specifier: 0.55.1
|
||||
version: 0.55.1
|
||||
motion:
|
||||
specifier: 12.4.13
|
||||
version: 12.4.13(@emotion/is-prop-valid@1.2.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
@@ -1752,105 +1755,89 @@ packages:
|
||||
resolution: {integrity: sha512-C0SqjoFKnszqa44EQ7xoaT48nnO0lOyXEULfXMWi8krrjOPGYkeK30Okzla6ATbBYsyZ0ySinK0FVkpv3DwzfQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.3.0':
|
||||
resolution: {integrity: sha512-A8UpHoUDW4DwnXoV6+q3C1s7QLRAHtPDEjWuNZjwHMyoCNZnm0GeNN8ls9f/bsEYTRQRW96C/n34XJQHJ2fT7A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.3.0':
|
||||
resolution: {integrity: sha512-WOpkVxAjFd369iaIzEgNRreFD+gWdUMIGD5zplhNKNeqS6mm5dac3q2AFyCBmzYoAdouzZvRBgxy4z8QHZb4/A==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.3.0':
|
||||
resolution: {integrity: sha512-DRWw0mOHusrCCuw2rqP87oLg6PGlkomVDFqw2hIwsSfwWpu4k3XLcBPaKKl6ct/GtL/cwNkgwjV/tc0Mqht3VA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.3.0':
|
||||
resolution: {integrity: sha512-9APy+nFWhHS+kzLgWZfLcyrUd7YqnAQVa4BPOo4xkoHpdoktOAPG4cEr9+Jpl0TtqfVmcMJimNL5qNTyyOHZNA==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.3.0':
|
||||
resolution: {integrity: sha512-y9RNUYDe2A1UAdhLyfeOodGRszQdaEoe4nfOpp/sNVPl2CWIcUyFaDoCh4vPLPxu19803j2naLqZup2WxDXCLA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.3.0':
|
||||
resolution: {integrity: sha512-cC1wkC0Mlucd0KSiGrLkJnB/ZqPvZCntc/Lk7ZnYO5ZSbF2euNek4Xvxafojq+wN1q/W0eprdpUIjUr/EV2PBg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.3.0':
|
||||
resolution: {integrity: sha512-LiYMhUZicB1QG//+RvmYZpXJO8fYRENfp+MZUCnG9aw+AKvGAy9gPaCnuwsPcBFs8EV66M0NNxj9VHcNklE8zw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.35.0':
|
||||
resolution: {integrity: sha512-4+4XHLNT5wDT0roYlHTEmH9lDKt0acf9Tv+3hM3iceOirkxrR404/3WjAYZ9F9CkHrxeRcGLJXbi4vluMZ9O+A==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.35.0':
|
||||
resolution: {integrity: sha512-VVlpEWwizEFIOom0zdoeKuO5nuTswzVE5uHcBNvHzmeHUpNFajY3HFfbQ+zIH4E2kVaZ/yVxmsShW56TtEy4uA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.35.0':
|
||||
resolution: {integrity: sha512-N3hzbEpUTJC8pWpPVJvgzGxM+so/MAXc8O2s/53B0LL9ZGpfXpME7Wizkc5d/8fRBlBtkDjzoZGDCqqNDHqLEw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.35.0':
|
||||
resolution: {integrity: sha512-l6vmKVPnbS0RhVMbyxP5meAARsbhCnBN4fy31qz0+3a6Rv4jEqfzDrT89y6ZPkCi0AJGnwp2En528yXo401Hpw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.35.0':
|
||||
resolution: {integrity: sha512-MYlMiPFiv/EKPAHnp3yNZ9AAWFsxga9c5Bkc6wkar6bqzHLlkGVJHRm0u1ei+VXnZxp3Mz9MG9ZIsI8vSOf3sQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.35.0':
|
||||
resolution: {integrity: sha512-TYaItB5oj1ioXjhyn2xrR208vf+YuIIcHptQWRRaBmFhvIvL9D72DXN8w75xup0KXA8UdEAhQ9Qb2S49FD/9Cw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.35.0':
|
||||
resolution: {integrity: sha512-DSTb6ijQzqe6DdAaOBVqJ/SYf1vO8EW5bK6X6LRXufEBebf2722VCdvBUtZ3rtV0x2ApfPNDy/p7LrrjaWjiyQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.35.0':
|
||||
resolution: {integrity: sha512-K7ykQ+26Rt6+4BTU80AuGgTPIYX86UxiAKT4rcXX/WNTo7k1ZxpKz+TguHnwVpCqQK3B5PK0vZ0ZBe6nz/ib1w==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.35.0':
|
||||
resolution: {integrity: sha512-9woLIFORERCr+6cWu87dQ22J34EExkhc73U1kZW0c+RclQqWetoodByp4dWZ/hN8/KVmTRAx2HOnUwib8AwZdA==}
|
||||
@@ -2231,56 +2218,48 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-arm64-musl@0.54.0':
|
||||
resolution: {integrity: sha512-QrwJlBFFKnxOd95TAaszpMbZBLzMoYMpGaQTZF8oibacnF5rv8l12IhILhQRPmksWiBqg0YSe2Mnl7ayeJAHSA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.54.0':
|
||||
resolution: {integrity: sha512-WILatiol/TUHTlhod7R09+7Az/XlhKwmY1MHfLZNmewltPWNN/EwxP2rQSHahibZ/cB8gmckEBjBOByD+5bYsQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.54.0':
|
||||
resolution: {integrity: sha512-f05YMG4BH4G8S4ME6UM6fi1MnJ9094mrnvO5Pa4SJlMfWlUM+1/ZWMEF4NnjM7shZAvbHsHRuVYpUo0PHC4P9Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.54.0':
|
||||
resolution: {integrity: sha512-UfL+2hj1ClNqcCRT9s8vBU4axDpjxgVxX96G+9DYAYjoc5b0u15CJtn2jgsi9iM+EbGNc5CW1HVRgwVu76UsSA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.54.0':
|
||||
resolution: {integrity: sha512-3/XZe931Hka+J6NjnaqJzYpsWWxDTuRdUdwSQHnOuJEgbC+SehIMFJS8hsEjV7LBhVSL2OCnRLvbVW8O97XIyw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-gnu@0.54.0':
|
||||
resolution: {integrity: sha512-Ik93RlObtu43GbxApafayFjwYE06L6Xr08cSwpBPYbDrLp2ReZx0Jm1DqwRyYRnukUJy+rK2WaEvUQOxdytU9Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-musl@0.54.0':
|
||||
resolution: {integrity: sha512-yZcakmPlD86CNymknd7KfW+FH+qfbqJH+i0h69CYfV1+KMoVeM9UED+8+TDVoU4haxI0NxY7RPCvRLy3Sqd2Qg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-openharmony-arm64@0.54.0':
|
||||
resolution: {integrity: sha512-GiVBZNnEZnKu00f1jTg49nomv187d0GQX+O+ocykoLeiaALuEO+swoTehHn9TehTfi7V8H0i0e/yvUjCqnwk1w==}
|
||||
@@ -2383,56 +2362,48 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-arm64-musl@1.69.0':
|
||||
resolution: {integrity: sha512-xEPpNppTfN1l/nM7gYSf9iocscu/as+p/7vxkLeLEKnYU+09Dm+5V6IhDYDh+Uz6FajEupWwCLt5SOG0y1PCKg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.69.0':
|
||||
resolution: {integrity: sha512-Ug0+eU7HJBlek+SjklYH62IlOMirEJsdxpihH0kSqX0XdrDD4NdHpQc10fK1JC35yn6KrrcN+uYzlHD38XAf8Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.69.0':
|
||||
resolution: {integrity: sha512-iEyI3GIg0l/s3G4qy2TlaaWKdzj4PJJStwtlocpDTC00PY9hZueotf6OKUj9+yfQh0lrpBW/pLMgTztbAHKJEg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-musl@1.69.0':
|
||||
resolution: {integrity: sha512-NjHjpiI4WIKSMwuoJSZi5VToPeoYOS1FR52HLIDG6lidMdqquusgtODb4iLk0+lb1q3Z0nv2/aPRcC/olmpQGg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-s390x-gnu@1.69.0':
|
||||
resolution: {integrity: sha512-Ai/prDewoItkDXbp38gwGZi41DycZbUTZJ3UidwoHgQC0/DaqC2TGdtBTQLJ6hSD+SAxASzh8+/eSBPmxfOacA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-gnu@1.69.0':
|
||||
resolution: {integrity: sha512-Gt3KHgp46mRKz4sJeaASmKvD8ayXookRw07RMf+NowhEztGGDZ7VrXpoW96XuKJLjFukWizOFVNjmYb/u7caNQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-musl@1.69.0':
|
||||
resolution: {integrity: sha512-7tQhJ2+p/oHv1zcfnjYI7YVzC/7iBaVOfIvFYtxdJ5F45mWgEdrCyXZXZGfiLey5t/5JhOhsaMnnv1kAzckd7g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-openharmony-arm64@1.69.0':
|
||||
resolution: {integrity: sha512-vmWz6TKp/3hfA4lksR0zHBv/6xuX1jhym6eqOjdH2DXsDDHZWcp2f0KG0VCAnlVbIrjk29G4wAWMXb/Hn1YobA==}
|
||||
@@ -2487,42 +2458,36 @@ packages:
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-arm-musl@2.5.1':
|
||||
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-linux-arm64-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-arm64-musl@2.5.1':
|
||||
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-linux-x64-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-x64-musl@2.5.1':
|
||||
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-win32-arm64@2.5.1':
|
||||
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
|
||||
@@ -3139,28 +3104,24 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.0-beta.53':
|
||||
resolution: {integrity: sha512-bGe5EBB8FVjHBR1mOLOPEFg1Lp3//7geqWkU5NIhxe+yH0W8FVrQ6WRYOap4SUTKdklD/dC4qPLREkMMQ855FA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.0-beta.53':
|
||||
resolution: {integrity: sha512-qL+63WKVQs1CMvFedlPt0U9PiEKJOAL/bsHMKUDS6Vp2Q+YAv/QLPu8rcvkfIMvQ0FPU2WL0aX4eWwF6e/GAnA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.0.0-beta.53':
|
||||
resolution: {integrity: sha512-VGl9JIGjoJh3H8Mb+7xnVqODajBmrdOOb9lxWXdcmxyI+zjB2sux69br0hZJDTyLJfvBoYm439zPACYbCjGRmw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.0.0-beta.53':
|
||||
resolution: {integrity: sha512-B4iIserJXuSnNzA5xBLFUIjTfhNy7d9sq4FUMQY3GhQWGVhS2RWWzzDnkSU6MUt7/aHUrep0CdQfXUJI9D3W7A==}
|
||||
@@ -3821,49 +3782,41 @@ packages:
|
||||
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
||||
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
||||
@@ -6272,28 +6225,24 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.31.1:
|
||||
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.31.1:
|
||||
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.31.1:
|
||||
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.31.1:
|
||||
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
|
||||
|
||||
@@ -14,6 +14,8 @@ import TimezoneProvider from 'providers/Timezone';
|
||||
import store from 'store';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import 'lib/monaco/setup';
|
||||
|
||||
import './ReactI18';
|
||||
|
||||
import 'styles.scss';
|
||||
|
||||
21
frontend/src/lib/monaco/setup.ts
Normal file
21
frontend/src/lib/monaco/setup.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { loader } from '@monaco-editor/react';
|
||||
import * as monaco from 'monaco-editor';
|
||||
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
||||
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
|
||||
|
||||
// Serve Monaco's workers from our own origin instead of letting @monaco-editor/loader
|
||||
// fetch them from cdn.jsdelivr.net at runtime. The CDN default breaks the editor in
|
||||
// air-gapped/on-prem installs, CDN-blocking corporate networks, and regions where
|
||||
// jsdelivr is unreachable. Ref: engineering-pod#5871, SIGNOZ-UI-5G0.
|
||||
self.MonacoEnvironment = {
|
||||
getWorker(_workerId: string, label: string): Worker {
|
||||
if (label === 'json') {
|
||||
return new JsonWorker(); // JSON language service
|
||||
}
|
||||
// SigNoz editors use JSON + a hand-registered ClickHouse tokenizer (no worker),
|
||||
// so the base editor worker covers everything else.
|
||||
return new EditorWorker(); // base worker — sql, yaml, plaintext etc.
|
||||
},
|
||||
};
|
||||
|
||||
loader.config({ monaco });
|
||||
@@ -993,8 +993,8 @@ func TestBuild_TraceList_MultiVariantGateKey(t *testing.T) {
|
||||
assert.Contains(t, got, "mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_number, 'gen_ai.tool.name')")
|
||||
}
|
||||
|
||||
// A `trace.`-prefixed aggregate in the filter box and the same condition in the
|
||||
// explicit Having box build the same query; output-only aggregates are rejected.
|
||||
// `trace.` marks a trace-level aggregate; `tracefield.` routes trace-level too but is
|
||||
// not a rewritable alias, so the HAVING rewriter rejects it.
|
||||
func TestBuild_TraceList_TraceContextPrefix(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
build := func(q qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) (*qbtypes.Statement, error) {
|
||||
@@ -1002,14 +1002,19 @@ func TestBuild_TraceList_TraceContextPrefix(t *testing.T) {
|
||||
return b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTrace, q, nil)
|
||||
}
|
||||
|
||||
viaTrace, err := build(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
_, err := build(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Filter: &qbtypes.Filter{Expression: "trace.output_tokens > 1000"}})
|
||||
require.NoError(t, err)
|
||||
|
||||
viaHaving, err := build(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Having: &qbtypes.Having{Expression: "trace.output_tokens > 1000"}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, viaTrace.Query, viaHaving.Query)
|
||||
_, err = build(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Filter: &qbtypes.Filter{Expression: "tracefield.output_tokens > 1000"}})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Invalid references in `Having` expression: [tracefield.output_tokens]")
|
||||
|
||||
_, err = build(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Having: &qbtypes.Having{Expression: "tracefield.output_tokens > 1000"}})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Invalid references in `Having` expression: [tracefield.output_tokens]")
|
||||
|
||||
_, err = build(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Filter: &qbtypes.Filter{Expression: "trace.span_count > 3"}})
|
||||
@@ -1017,8 +1022,7 @@ func TestBuild_TraceList_TraceContextPrefix(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "cannot be used")
|
||||
}
|
||||
|
||||
// Query variables in a trace-level condition resolve like span filters: bound args,
|
||||
// list/IN handling, dynamic __all__ dropping the condition.
|
||||
// Query variables in a trace-level condition are substituted into the HAVING.
|
||||
func TestBuild_TraceList_VariableInAggregateFilter(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
build := func(expr string, vars map[string]qbtypes.VariableItem) (*qbtypes.Statement, error) {
|
||||
@@ -1030,18 +1034,17 @@ func TestBuild_TraceList_VariableInAggregateFilter(t *testing.T) {
|
||||
}, vars)
|
||||
}
|
||||
|
||||
// scalar variable -> bound arg via the filter pipeline
|
||||
// scalar variable -> literal in HAVING
|
||||
stmt, err := build("trace.output_tokens > $threshold",
|
||||
map[string]qbtypes.VariableItem{"threshold": {Value: 700}})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, stmt.Query, "HAVING output_tokens > ?")
|
||||
assert.Contains(t, stmt.Args, float64(700))
|
||||
assert.Contains(t, stmt.Query, "HAVING output_tokens > 700")
|
||||
|
||||
// list variable with IN
|
||||
stmt, err = build("trace.llm_call_count IN $counts",
|
||||
map[string]qbtypes.VariableItem{"counts": {Value: []any{1, 2}}})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, stmt.Query, "HAVING llm_call_count IN (?, ?)")
|
||||
assert.Contains(t, stmt.Query, "HAVING llm_call_count IN")
|
||||
|
||||
// dynamic __all__ -> condition dropped, no HAVING at all
|
||||
stmt, err = build("trace.output_tokens > $threshold",
|
||||
@@ -1049,7 +1052,7 @@ func TestBuild_TraceList_VariableInAggregateFilter(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, stmt.Query, "HAVING")
|
||||
|
||||
// unresolved variable -> rejected, though only as an unknown aggregate today
|
||||
// unresolved variable -> rejected, not compared as a literal
|
||||
_, err = build("trace.output_tokens > $missing", map[string]qbtypes.VariableItem{"other": {Value: 1}})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -1,781 +0,0 @@
|
||||
package aistatementbuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The builder assumes at least one aggregation; request validation is what enforces it.
|
||||
func TestBuild_Aggregation_NoAggregations_RejectedByRequestValidation(t *testing.T) {
|
||||
for _, rt := range []qbtypes.RequestType{qbtypes.RequestTypeScalar, qbtypes.RequestTypeTimeSeries} {
|
||||
req := qbtypes.QueryRangeRequest{
|
||||
Start: testStartMs,
|
||||
End: testEndMs,
|
||||
RequestType: rt,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{{
|
||||
Type: qbtypes.QueryTypeBuilderAI,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
require.ErrorContains(t, req.Validate(), "at least one aggregation is required", rt.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
// Traces without token spans yield NULL, which the outer avg skips.
|
||||
func TestBuild_FullSQL_Scalar_TraceAgg(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertSQLEqual(t, `
|
||||
WITH __scoped_traces AS (
|
||||
SELECT trace_id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
GROUP BY trace_id
|
||||
)
|
||||
SELECT avg(output_tokens) AS __result_0
|
||||
FROM __scoped_traces
|
||||
ORDER BY __result_0 DESC
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// A span-level filter is ANDed into the per-trace scan's WHERE, next to the gate mask.
|
||||
func TestBuild_FullSQL_Scalar_SpanFilter(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
|
||||
Filter: &qbtypes.Filter{Expression: "gen_ai.request.model = 'gpt-4o-mini'"},
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertSQLEqual(t, `
|
||||
WITH __scoped_traces AS (
|
||||
SELECT trace_id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
AND (attributes_string['gen_ai.request.model'] = 'gpt-4o-mini' AND mapContains(attributes_string, 'gen_ai.request.model'))
|
||||
GROUP BY trace_id
|
||||
)
|
||||
SELECT avg(output_tokens) AS __result_0
|
||||
FROM __scoped_traces
|
||||
ORDER BY __result_0 DESC
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// A trace-level filter qualifies first: __qualified holds the trace ids whose
|
||||
// whole-window value passes, and the per-trace scan is constrained to them.
|
||||
func TestBuild_FullSQL_Scalar_TraceFilter(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
|
||||
Filter: &qbtypes.Filter{Expression: "trace.output_tokens > 1000"},
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertSQLEqual(t, `
|
||||
WITH __qualified AS (
|
||||
SELECT trace_id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
GROUP BY trace_id
|
||||
HAVING output_tokens > 1000
|
||||
),
|
||||
__scoped_traces AS (
|
||||
SELECT trace_id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
AND trace_id GLOBAL IN (SELECT trace_id FROM __qualified)
|
||||
GROUP BY trace_id
|
||||
)
|
||||
SELECT avg(output_tokens) AS __result_0
|
||||
FROM __scoped_traces
|
||||
ORDER BY __result_0 DESC
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// A group-by column is grouped in the per-trace scan too, so a trace spanning two
|
||||
// models contributes one per-trace row per model.
|
||||
func TestBuild_FullSQL_Scalar_GroupBy(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "gen_ai.request.model"}}},
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertSQLEqual(t, `
|
||||
WITH __scoped_traces AS (
|
||||
SELECT trace_id,
|
||||
toString(multiIf(mapContains(attributes_string, 'gen_ai.request.model'), attributes_string['gen_ai.request.model'], NULL)) AS __GROUP_BY_KEY_0_gen_ai.request.model,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
GROUP BY trace_id, __GROUP_BY_KEY_0_gen_ai.request.model
|
||||
)
|
||||
SELECT __GROUP_BY_KEY_0_gen_ai.request.model, avg(output_tokens) AS __result_0
|
||||
FROM __scoped_traces
|
||||
GROUP BY __GROUP_BY_KEY_0_gen_ai.request.model
|
||||
ORDER BY __result_0 DESC
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// Grouping by an intrinsic: the positional alias keeps `toString(name) AS name` (a cyclic
|
||||
// alias) from forming, and an order key on the dimension resolves to that alias.
|
||||
func TestBuild_FullSQL_Scalar_GroupByIntrinsic(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "name"}}},
|
||||
Order: []qbtypes.OrderBy{{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "name"}}, Direction: qbtypes.OrderDirectionAsc}},
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertSQLEqual(t, `
|
||||
WITH __scoped_traces AS (
|
||||
SELECT trace_id,
|
||||
toString(multiIf(name <> '', toString(name), NULL)) AS __GROUP_BY_KEY_0_name,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
GROUP BY trace_id, __GROUP_BY_KEY_0_name
|
||||
)
|
||||
SELECT __GROUP_BY_KEY_0_name, avg(output_tokens) AS __result_0
|
||||
FROM __scoped_traces
|
||||
GROUP BY __GROUP_BY_KEY_0_name
|
||||
ORDER BY __GROUP_BY_KEY_0_name asc
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// Every dimension at once; the HAVING on the alias is rewritten to __result_0.
|
||||
func TestBuild_FullSQL_Scalar_FullCombo(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{Expression: "avg(trace.output_tokens)", Alias: "avg_out"},
|
||||
{Expression: "count(trace.trace_id)"},
|
||||
},
|
||||
Filter: &qbtypes.Filter{Expression: "gen_ai.request.model = 'gpt-4o-mini' AND trace.total_tokens > 100"},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "gen_ai.request.model"}}},
|
||||
Having: &qbtypes.Having{Expression: "avg_out > 50"},
|
||||
Order: []qbtypes.OrderBy{{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "avg_out"}}, Direction: qbtypes.OrderDirectionDesc}},
|
||||
Limit: 5,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertSQLEqual(t, `
|
||||
WITH __qualified AS (
|
||||
SELECT trace_id,
|
||||
coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens'), toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)), 0) + coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)), 0) AS total_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
GROUP BY trace_id
|
||||
HAVING total_tokens > 100
|
||||
),
|
||||
__scoped_traces AS (
|
||||
SELECT trace_id,
|
||||
toString(multiIf(mapContains(attributes_string, 'gen_ai.request.model'), attributes_string['gen_ai.request.model'], NULL)) AS __GROUP_BY_KEY_0_gen_ai.request.model,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
AND (attributes_string['gen_ai.request.model'] = 'gpt-4o-mini' AND mapContains(attributes_string, 'gen_ai.request.model'))
|
||||
AND trace_id GLOBAL IN (SELECT trace_id FROM __qualified)
|
||||
GROUP BY trace_id, __GROUP_BY_KEY_0_gen_ai.request.model
|
||||
)
|
||||
SELECT __GROUP_BY_KEY_0_gen_ai.request.model, avg(output_tokens) AS __result_0, count(trace_id) AS __result_1
|
||||
FROM __scoped_traces
|
||||
GROUP BY __GROUP_BY_KEY_0_gen_ai.request.model
|
||||
HAVING __result_0 > 50
|
||||
ORDER BY __result_0 desc
|
||||
LIMIT 5
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// Time series: the per-trace scan buckets by span time, the outer aggregation per bucket.
|
||||
func TestBuild_FullSQL_TimeSeries_TraceAgg(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTimeSeries,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertSQLEqual(t, `
|
||||
WITH __scoped_traces AS (
|
||||
SELECT trace_id,
|
||||
toStartOfInterval(timestamp, INTERVAL 60 SECOND) AS ts,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
GROUP BY trace_id, ts
|
||||
)
|
||||
SELECT ts, avg(output_tokens) AS __result_0
|
||||
FROM __scoped_traces
|
||||
GROUP BY ts
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// A grouped, limited time series ranks groups on unbucketed whole-window values
|
||||
// (__scoped_traces_total), so a non-composable aggregate like avg ranks exactly.
|
||||
func TestBuild_FullSQL_TimeSeries_GroupLimit(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTimeSeries,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "sum(trace.output_tokens)", Alias: "total_out"}},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "gen_ai.request.model"}}},
|
||||
Having: &qbtypes.Having{Expression: "total_out > 500"},
|
||||
Order: []qbtypes.OrderBy{{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "total_out"}}, Direction: qbtypes.OrderDirectionDesc}},
|
||||
Limit: 3,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertSQLEqual(t, `
|
||||
WITH __scoped_traces_total AS (
|
||||
SELECT trace_id,
|
||||
toString(multiIf(mapContains(attributes_string, 'gen_ai.request.model'), attributes_string['gen_ai.request.model'], NULL)) AS __GROUP_BY_KEY_0_gen_ai.request.model,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
GROUP BY trace_id, __GROUP_BY_KEY_0_gen_ai.request.model
|
||||
),
|
||||
__limit_cte AS (
|
||||
SELECT __GROUP_BY_KEY_0_gen_ai.request.model, sum(output_tokens) AS __result_0
|
||||
FROM __scoped_traces_total
|
||||
GROUP BY __GROUP_BY_KEY_0_gen_ai.request.model
|
||||
ORDER BY __result_0 desc
|
||||
LIMIT 3
|
||||
),
|
||||
__scoped_traces AS (
|
||||
SELECT trace_id,
|
||||
toStartOfInterval(timestamp, INTERVAL 60 SECOND) AS ts,
|
||||
toString(multiIf(mapContains(attributes_string, 'gen_ai.request.model'), attributes_string['gen_ai.request.model'], NULL)) AS __GROUP_BY_KEY_0_gen_ai.request.model,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
AND (toString(multiIf(mapContains(attributes_string, 'gen_ai.request.model'), attributes_string['gen_ai.request.model'], NULL))) GLOBAL IN (SELECT __GROUP_BY_KEY_0_gen_ai.request.model FROM __limit_cte)
|
||||
GROUP BY trace_id, ts, __GROUP_BY_KEY_0_gen_ai.request.model
|
||||
)
|
||||
SELECT ts, __GROUP_BY_KEY_0_gen_ai.request.model, sum(output_tokens) AS __result_0
|
||||
FROM __scoped_traces
|
||||
GROUP BY ts, __GROUP_BY_KEY_0_gen_ai.request.model
|
||||
HAVING __result_0 > 500
|
||||
ORDER BY ts desc
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// A span-level scalar delegates to the trace builder, constrained by __trace_scope;
|
||||
// the shape is the delegate's own, hence no SETTINGS suffix.
|
||||
func TestBuild_FullSQL_Scalar_SpanAgg_TraceScoped(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "sum(gen_ai.usage.output_tokens)"}},
|
||||
Filter: &qbtypes.Filter{Expression: "trace.output_tokens > 1000"},
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertSQLEqual(t, `
|
||||
WITH __trace_scope AS (
|
||||
SELECT trace_id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
GROUP BY trace_id
|
||||
HAVING output_tokens > 1000
|
||||
)
|
||||
SELECT sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS __result_0
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE trace_id GLOBAL IN (SELECT trace_id FROM __trace_scope)
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
AND timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
ORDER BY __result_0 DESC
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// Two group keys make the top-N prune a 2-tuple GLOBAL IN, and the qualification plus
|
||||
// span predicate apply to the ranking scan and the main scan alike.
|
||||
func TestBuild_FullSQL_TimeSeries_GroupLimit_MultiKey(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTimeSeries,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{Expression: "sum(trace.output_tokens)"},
|
||||
{Expression: "count(trace.trace_id)"},
|
||||
},
|
||||
Filter: &qbtypes.Filter{Expression: "gen_ai.request.model = 'gpt-4o-mini' AND trace.total_tokens > 100"},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "gen_ai.request.model"}},
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "gen_ai.user.id"}},
|
||||
},
|
||||
Limit: 2,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertSQLEqual(t, `
|
||||
WITH __qualified AS (
|
||||
SELECT trace_id,
|
||||
coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens'), toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)), 0) + coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)), 0) AS total_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
GROUP BY trace_id
|
||||
HAVING total_tokens > 100
|
||||
),
|
||||
__scoped_traces_total AS (
|
||||
SELECT trace_id,
|
||||
toString(multiIf(mapContains(attributes_string, 'gen_ai.request.model'), attributes_string['gen_ai.request.model'], NULL)) AS __GROUP_BY_KEY_0_gen_ai.request.model,
|
||||
toString(multiIf(mapContains(attributes_string, 'gen_ai.user.id'), attributes_string['gen_ai.user.id'], NULL)) AS __GROUP_BY_KEY_1_gen_ai.user.id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
AND (attributes_string['gen_ai.request.model'] = 'gpt-4o-mini' AND mapContains(attributes_string, 'gen_ai.request.model'))
|
||||
AND trace_id GLOBAL IN (SELECT trace_id FROM __qualified)
|
||||
GROUP BY trace_id, __GROUP_BY_KEY_0_gen_ai.request.model, __GROUP_BY_KEY_1_gen_ai.user.id
|
||||
),
|
||||
__limit_cte AS (
|
||||
SELECT __GROUP_BY_KEY_0_gen_ai.request.model, __GROUP_BY_KEY_1_gen_ai.user.id, sum(output_tokens) AS __result_0, count(trace_id) AS __result_1
|
||||
FROM __scoped_traces_total
|
||||
GROUP BY __GROUP_BY_KEY_0_gen_ai.request.model, __GROUP_BY_KEY_1_gen_ai.user.id
|
||||
ORDER BY __result_0 DESC
|
||||
LIMIT 2
|
||||
),
|
||||
__scoped_traces AS (
|
||||
SELECT trace_id,
|
||||
toStartOfInterval(timestamp, INTERVAL 60 SECOND) AS ts,
|
||||
toString(multiIf(mapContains(attributes_string, 'gen_ai.request.model'), attributes_string['gen_ai.request.model'], NULL)) AS __GROUP_BY_KEY_0_gen_ai.request.model,
|
||||
toString(multiIf(mapContains(attributes_string, 'gen_ai.user.id'), attributes_string['gen_ai.user.id'], NULL)) AS __GROUP_BY_KEY_1_gen_ai.user.id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
AND (attributes_string['gen_ai.request.model'] = 'gpt-4o-mini' AND mapContains(attributes_string, 'gen_ai.request.model'))
|
||||
AND trace_id GLOBAL IN (SELECT trace_id FROM __qualified)
|
||||
AND (toString(multiIf(mapContains(attributes_string, 'gen_ai.request.model'), attributes_string['gen_ai.request.model'], NULL)), toString(multiIf(mapContains(attributes_string, 'gen_ai.user.id'), attributes_string['gen_ai.user.id'], NULL))) GLOBAL IN (SELECT __GROUP_BY_KEY_0_gen_ai.request.model, __GROUP_BY_KEY_1_gen_ai.user.id FROM __limit_cte)
|
||||
GROUP BY trace_id, ts, __GROUP_BY_KEY_0_gen_ai.request.model, __GROUP_BY_KEY_1_gen_ai.user.id
|
||||
)
|
||||
SELECT ts, __GROUP_BY_KEY_0_gen_ai.request.model, __GROUP_BY_KEY_1_gen_ai.user.id, sum(output_tokens) AS __result_0, count(trace_id) AS __result_1
|
||||
FROM __scoped_traces
|
||||
GROUP BY ts, __GROUP_BY_KEY_0_gen_ai.request.model, __GROUP_BY_KEY_1_gen_ai.user.id
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// A time-series limit without group-by has nothing to rank: it is ignored, matching
|
||||
// the trace builder — the query equals its unlimited form.
|
||||
func TestBuild_TimeSeries_LimitWithoutGroupByIgnored(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
build := func(limit int) *qbtypes.Statement {
|
||||
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTimeSeries,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
|
||||
Limit: limit,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
return stmt
|
||||
}
|
||||
assert.Equal(t, build(0).Query, build(5).Query)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Behavior / branch tests not covered by the goldens above
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Mixing span- and trace-level aggregations across one query is rejected.
|
||||
func TestBuild_Aggregation_MixedDomainsRejected(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
_, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{Expression: "avg(trace.output_tokens)"},
|
||||
{Expression: "sum(gen_ai.usage.output_tokens)"},
|
||||
},
|
||||
}, nil)
|
||||
require.ErrorContains(t, err, "cannot be mixed")
|
||||
}
|
||||
|
||||
// Output-only aggregates are rejected in trace-level filters on the aggregation
|
||||
// path too (the raw and trace-list paths are covered elsewhere).
|
||||
func TestBuild_Aggregation_OutputOnlyFilterRejected(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
_, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "count()"}},
|
||||
Filter: &qbtypes.Filter{Expression: "trace.span_count > 3"},
|
||||
}, nil)
|
||||
require.ErrorContains(t, err, `aggregate "span_count" cannot be used`)
|
||||
}
|
||||
|
||||
// Trace-level columns are rejected as group-by keys; order keys never reach the builder,
|
||||
// since request validation only admits group keys and aggregation aliases/expressions.
|
||||
func TestBuild_Aggregation_GroupByOrderValidation(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "trace.llm_call_count"}}},
|
||||
}, nil)
|
||||
require.ErrorContains(t, err, `grouping by trace-level aggregate "trace.llm_call_count" is not supported`)
|
||||
|
||||
req := qbtypes.QueryRangeRequest{
|
||||
Start: testStartMs,
|
||||
End: testEndMs,
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{{
|
||||
Type: qbtypes.QueryTypeBuilderAI,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
|
||||
Order: []qbtypes.OrderBy{{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "trace.total_tokens"}}, Direction: qbtypes.OrderDirectionDesc}},
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
require.ErrorContains(t, req.Validate(), "invalid order by key")
|
||||
|
||||
_, err = b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)", Alias: "avg_out"}},
|
||||
Order: []qbtypes.OrderBy{{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "avg_out"}}, Direction: qbtypes.OrderDirectionAsc}},
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Variables in trace-level conditions resolve as bound args; a dynamic __all__ drops the
|
||||
// condition, and an unresolved $var is rejected only as an unknown aggregate today.
|
||||
func TestBuild_FullSQL_Aggregation_VariablesInTraceFilter(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
ctx := context.Background()
|
||||
|
||||
q := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
|
||||
Filter: &qbtypes.Filter{Expression: "trace.output_tokens > $threshold"},
|
||||
}
|
||||
stmt, err := b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar, q,
|
||||
map[string]qbtypes.VariableItem{"threshold": {Type: qbtypes.TextBoxVariableType, Value: float64(1000)}})
|
||||
require.NoError(t, err)
|
||||
assertSQLEqual(t, `
|
||||
WITH __qualified AS (
|
||||
SELECT trace_id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
GROUP BY trace_id
|
||||
HAVING output_tokens > 1000
|
||||
),
|
||||
__scoped_traces AS (
|
||||
SELECT trace_id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
AND trace_id GLOBAL IN (SELECT trace_id FROM __qualified)
|
||||
GROUP BY trace_id
|
||||
)
|
||||
SELECT avg(output_tokens) AS __result_0
|
||||
FROM __scoped_traces
|
||||
ORDER BY __result_0 DESC
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
|
||||
// an unresolved $var is only rejected as an unknown aggregate today; a targeted
|
||||
// "unknown variable" error is a separate concern
|
||||
_, err = b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar, q, nil)
|
||||
require.ErrorContains(t, err, `aggregate "$threshold" cannot be used`)
|
||||
|
||||
// __all__ drops the condition: the query equals its unfiltered form
|
||||
stmt, err = b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar, q,
|
||||
map[string]qbtypes.VariableItem{"threshold": {Type: qbtypes.DynamicVariableType, Value: "__all__"}})
|
||||
require.NoError(t, err)
|
||||
unfiltered := q
|
||||
unfiltered.Filter = nil
|
||||
want, err := b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar, unfiltered, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want.Query, stmt.Query)
|
||||
|
||||
// list variables render as IN with bound args; the scan selects only trace_id
|
||||
// since no aggregation touches a per-trace column
|
||||
stmt, err = b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "count(trace.trace_id)"}},
|
||||
Filter: &qbtypes.Filter{Expression: "trace.llm_call_count IN $counts"},
|
||||
}, map[string]qbtypes.VariableItem{
|
||||
"counts": {Type: qbtypes.QueryVariableType, Value: []any{float64(1), float64(2)}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assertSQLEqual(t, `
|
||||
WITH __qualified AS (
|
||||
SELECT trace_id,
|
||||
countIf(mapContains(attributes_string, 'gen_ai.request.model')) AS llm_call_count
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
GROUP BY trace_id
|
||||
HAVING llm_call_count IN (1, 2)
|
||||
),
|
||||
__scoped_traces AS (
|
||||
SELECT trace_id
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
AND trace_id GLOBAL IN (SELECT trace_id FROM __qualified)
|
||||
GROUP BY trace_id
|
||||
)
|
||||
SELECT count(trace_id) AS __result_0
|
||||
FROM __scoped_traces
|
||||
ORDER BY __result_0 DESC
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// Resource conditions on the native path: the __resource_filter CTE prunes the
|
||||
// qualification scan and the per-trace scan by fingerprint.
|
||||
func TestBuild_FullSQL_Aggregation_ResourceFilter_Native(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'api' AND trace.output_tokens > 1000"},
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertSQLEqual(t, `
|
||||
WITH __resource_filter AS (
|
||||
SELECT fingerprint
|
||||
FROM signoz_traces.distributed_traces_v3_resource
|
||||
WHERE (simpleJSONExtractString(labels, 'service.name') = 'api' AND labels LIKE '%service.name%' AND labels LIKE '%service.name":"api%')
|
||||
AND seen_at_ts_bucket_start >= 1747945619
|
||||
AND seen_at_ts_bucket_start <= 1747983448
|
||||
GROUP BY fingerprint
|
||||
),
|
||||
__qualified AS (
|
||||
SELECT trace_id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
AND resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)
|
||||
GROUP BY trace_id
|
||||
HAVING output_tokens > 1000
|
||||
),
|
||||
__scoped_traces AS (
|
||||
SELECT trace_id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
AND resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)
|
||||
AND trace_id GLOBAL IN (SELECT trace_id FROM __qualified)
|
||||
GROUP BY trace_id
|
||||
)
|
||||
SELECT avg(output_tokens) AS __result_0
|
||||
FROM __scoped_traces
|
||||
ORDER BY __result_0 DESC
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// On the delegated path __trace_scope inlines its fingerprint subquery, since it is built
|
||||
// without the delegate's CTEs, while the delegate keeps its own __resource_filter CTE.
|
||||
func TestBuild_FullSQL_Aggregation_ResourceFilter_Delegated(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "sum(gen_ai.usage.output_tokens)"}},
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'api' AND trace.output_tokens > 1000"},
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertSQLEqual(t, `
|
||||
WITH __resource_filter AS (
|
||||
SELECT fingerprint
|
||||
FROM signoz_traces.distributed_traces_v3_resource
|
||||
WHERE ((simpleJSONExtractString(labels, 'service.name') = 'api' AND labels LIKE '%service.name%' AND labels LIKE '%service.name":"api%'))
|
||||
AND seen_at_ts_bucket_start >= 1747945619
|
||||
AND seen_at_ts_bucket_start <= 1747983448
|
||||
GROUP BY fingerprint
|
||||
),
|
||||
__trace_scope AS (
|
||||
SELECT trace_id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
AND resource_fingerprint GLOBAL IN (SELECT fingerprint FROM (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = 'api' AND labels LIKE '%service.name%' AND labels LIKE '%service.name":"api%') AND seen_at_ts_bucket_start >= 1747945619 AND seen_at_ts_bucket_start <= 1747983448 GROUP BY fingerprint))
|
||||
GROUP BY trace_id
|
||||
HAVING output_tokens > 1000
|
||||
)
|
||||
SELECT sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS __result_0
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)
|
||||
AND trace_id GLOBAL IN (SELECT trace_id FROM __trace_scope)
|
||||
AND (((mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))) AND ((multiIf(resource.service.name IS NOT NULL, resource.service.name::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = 'api' AND multiIf(resource.service.name IS NOT NULL, resource.service.name::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL)))
|
||||
AND timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
ORDER BY __result_0 DESC
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// rate() divides by the window (scalar) / step (series). Per AggreFuncMap it counts
|
||||
// per-trace rows per second; it does not sum the column.
|
||||
func TestBuild_Aggregation_RateDividesByInterval(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
ctx := context.Background()
|
||||
q := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "rate(trace.llm_call_count)"}},
|
||||
}
|
||||
|
||||
stmt, err := b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar, q, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, stmt.Query, "count(llm_call_count)/36029 AS __result_0") // (end-start) seconds
|
||||
|
||||
q.StepInterval = qbtypes.Step{Duration: 60 * time.Second}
|
||||
stmt, err = b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTimeSeries, q, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, stmt.Query, "count(llm_call_count)/60 AS __result_0")
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
qbvariables "github.com/SigNoz/signoz/pkg/variables"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
@@ -113,8 +115,6 @@ func (b *scopedTraceStatementBuilder) Build(
|
||||
return b.buildTraceListQuery(ctx, orgID, querybuilder.ToNanoSecs(start), querybuilder.ToNanoSecs(end), query, variables)
|
||||
case qbtypes.RequestTypeRaw:
|
||||
return b.buildDelegated(ctx, orgID, start, end, requestType, query, variables)
|
||||
case qbtypes.RequestTypeScalar, qbtypes.RequestTypeTimeSeries:
|
||||
return b.buildAggregation(ctx, orgID, start, end, requestType, query, variables)
|
||||
default:
|
||||
return nil, ErrUnsupportedRequestType
|
||||
}
|
||||
@@ -143,61 +143,6 @@ func (b *scopedTraceStatementBuilder) buildDelegated(
|
||||
return b.traceStmtBuilder.Build(ctx, orgID, start, end, requestType, gated, variables)
|
||||
}
|
||||
|
||||
// traceScopedStatementBuilder is the delegate's optional capability of constraining a
|
||||
// query to a set of trace ids (implemented by the traces statement builder).
|
||||
type traceScopedStatementBuilder interface {
|
||||
BuildTraceScoped(ctx context.Context, orgID valuer.UUID, start, end uint64, requestType qbtypes.RequestType, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], variables map[string]qbtypes.VariableItem, traceScope *qbtypes.Statement) (*qbtypes.Statement, error)
|
||||
}
|
||||
|
||||
// buildDelegatedAggregation serves span-level scalar/time-series through the standard
|
||||
// trace builder, with the gate ANDed into the span-level filter part; a trace-level
|
||||
// part becomes a qualification the delegate constrains trace_id by.
|
||||
func (b *scopedTraceStatementBuilder) buildDelegatedAggregation(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end uint64,
|
||||
requestType qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
var spanExpr, traceExpr string
|
||||
var err error
|
||||
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
|
||||
spanExpr, traceExpr, err = querybuilder.SplitFilterForAggregates(query.Filter.Expression, b.aggregateAliasSet())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
gate := b.scope.FilterExpression
|
||||
expr := gate
|
||||
if strings.TrimSpace(spanExpr) != "" {
|
||||
expr = fmt.Sprintf("(%s) AND (%s)", gate, spanExpr)
|
||||
}
|
||||
|
||||
// shallow copy; only Filter is replaced, caller's query untouched
|
||||
gated := query
|
||||
gated.Filter = &qbtypes.Filter{Expression: expr}
|
||||
|
||||
if strings.TrimSpace(traceExpr) == "" {
|
||||
return b.traceStmtBuilder.Build(ctx, orgID, start, end, requestType, gated, variables)
|
||||
}
|
||||
|
||||
scoped, ok := b.traceStmtBuilder.(traceScopedStatementBuilder)
|
||||
if !ok {
|
||||
return nil, errors.NewInternalf(errors.CodeInternal, "trace statement builder does not support trace-scoped queries")
|
||||
}
|
||||
scope, err := b.buildQualifiedStatement(ctx, orgID, querybuilder.ToNanoSecs(start), querybuilder.ToNanoSecs(end), traceExpr, query, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if scope == nil {
|
||||
// every trace-level condition was dropped by variable resolution
|
||||
return b.traceStmtBuilder.Build(ctx, orgID, start, end, requestType, gated, variables)
|
||||
}
|
||||
return scoped.BuildTraceScoped(ctx, orgID, start, end, requestType, gated, variables, scope)
|
||||
}
|
||||
|
||||
// buildTraceListQuery wires the CTE pipeline (start/end are nanoseconds):
|
||||
// matched (windowed, mask-pruned top-N trace_ids) → ranked (their [start,end] from
|
||||
// the summary table) → buckets (ts_bucket_start prune) → enrichment (every per-trace
|
||||
@@ -239,17 +184,22 @@ func (b *scopedTraceStatementBuilder) buildTraceListQuery(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orderableSet := orderableAliasSet(resolved)
|
||||
|
||||
resourceFrag, resourceArgs, resourcePred, err := b.maybeAttachResourceFilter(ctx, orgID, query, start, end, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fp, err := b.splitFilter(ctx, orgID, query, b.aggregateAliasSet(), start, end, variables, matchedSB)
|
||||
fp, err := b.splitFilter(ctx, orgID, query, b.aggregateAliasSet(), orderableSet, start, end, variables, matchedSB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
matchedFrag, matchedArgs := b.buildMatchedCTE(matchedSB, start, end, startBucket, endBucket, resolved, orders, maskExpr, fp, resourcePred, limit, query.Offset)
|
||||
matchedFrag, matchedArgs, err := b.buildMatchedCTE(matchedSB, start, end, startBucket, endBucket, resolved, orders, orderableSet, maskExpr, fp, resourcePred, limit, query.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rankedFrag, rankedArgs := b.buildRankedCTE(start, end)
|
||||
|
||||
adj := querybuilder.BucketAdjustment // 30-min bucket width in seconds
|
||||
@@ -430,27 +380,27 @@ func (b *scopedTraceStatementBuilder) resolveListOrders(order []qbtypes.OrderBy,
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
// filterParts is the user filter split into a span-level predicate and the resolved
|
||||
// trace-level HAVING (nil when there is none).
|
||||
// filterParts is the user filter split into a span-level predicate and a trace-level
|
||||
// HAVING expression.
|
||||
type filterParts struct {
|
||||
spanPred string
|
||||
hasSpanFilter bool
|
||||
having *traceHaving
|
||||
havingExpr string
|
||||
warnings []string
|
||||
warningsURL string
|
||||
}
|
||||
|
||||
// splitFilter splits query.Filter into a span-level predicate and a trace-level
|
||||
// HAVING (explicit query.Having ANDed on before resolution); args bind into sb.
|
||||
func (b *scopedTraceStatementBuilder) splitFilter(ctx context.Context, orgID valuer.UUID, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], classifySet map[string]struct{}, start, end uint64, variables map[string]qbtypes.VariableItem, sb *sqlbuilder.SelectBuilder) (filterParts, error) {
|
||||
// splitFilter splits query.Filter into a span-level predicate (args bound into sb)
|
||||
// and a trace-level HAVING (explicit query.Having ANDed on), then validates the
|
||||
// trace-level part against the matched-pass aggregates.
|
||||
func (b *scopedTraceStatementBuilder) splitFilter(ctx context.Context, orgID valuer.UUID, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], classifySet, orderableSet map[string]struct{}, start, end uint64, variables map[string]qbtypes.VariableItem, sb *sqlbuilder.SelectBuilder) (filterParts, error) {
|
||||
var fp filterParts
|
||||
havingExpr := ""
|
||||
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
|
||||
spanExpr, traceExpr, err := querybuilder.SplitFilterForAggregates(query.Filter.Expression, classifySet)
|
||||
if err != nil {
|
||||
return fp, err
|
||||
}
|
||||
havingExpr = traceExpr
|
||||
fp.havingExpr = traceExpr
|
||||
if strings.TrimSpace(spanExpr) != "" {
|
||||
pred, warnings, url, err := b.resolveSpanPredicate(ctx, orgID, start, end, spanExpr, variables, sb)
|
||||
if err != nil {
|
||||
@@ -465,17 +415,23 @@ func (b *scopedTraceStatementBuilder) splitFilter(ctx context.Context, orgID val
|
||||
}
|
||||
}
|
||||
if query.Having != nil && strings.TrimSpace(query.Having.Expression) != "" {
|
||||
if havingExpr != "" {
|
||||
havingExpr = fmt.Sprintf("(%s) AND (%s)", havingExpr, query.Having.Expression)
|
||||
if fp.havingExpr != "" {
|
||||
fp.havingExpr = fmt.Sprintf("(%s) AND (%s)", fp.havingExpr, query.Having.Expression)
|
||||
} else {
|
||||
havingExpr = query.Having.Expression
|
||||
fp.havingExpr = query.Having.Expression
|
||||
}
|
||||
}
|
||||
having, err := b.resolveTraceHaving(ctx, havingExpr, variables, sb)
|
||||
if err != nil {
|
||||
// the HAVING is a plain text rewrite, so substitute variables here
|
||||
if strings.TrimSpace(fp.havingExpr) != "" && len(variables) > 0 {
|
||||
replaced, err := qbvariables.ReplaceVariablesInExpression(fp.havingExpr, variables)
|
||||
if err != nil {
|
||||
return fp, err
|
||||
}
|
||||
fp.havingExpr = replaced
|
||||
}
|
||||
if err := validateAggregateFilter(fp.havingExpr, orderableSet); err != nil {
|
||||
return fp, err
|
||||
}
|
||||
fp.having = having
|
||||
return fp, nil
|
||||
}
|
||||
|
||||
@@ -517,8 +473,8 @@ func (b *scopedTraceStatementBuilder) resolveSpanPredicate(ctx context.Context,
|
||||
// span filter + HAVING + ORDER BY + LIMIT/OFFSET, selecting only the aliases ORDER BY
|
||||
// / HAVING reference. Expressions carry $n markers bound to sb, so each can appear
|
||||
// several times and every occurrence resolves to the same arg.
|
||||
func (b *scopedTraceStatementBuilder) buildMatchedCTE(sb *sqlbuilder.SelectBuilder, start, end, startBucket, endBucket uint64, resolved []resolvedColumn, orders []listOrder, maskExpr string, fp filterParts, resourcePred string, limit, offset int) (string, []any) {
|
||||
needed := neededMatchedAliases(orders, fp.having)
|
||||
func (b *scopedTraceStatementBuilder) buildMatchedCTE(sb *sqlbuilder.SelectBuilder, start, end, startBucket, endBucket uint64, resolved []resolvedColumn, orders []listOrder, orderableSet map[string]struct{}, maskExpr string, fp filterParts, resourcePred string, limit, offset int) (string, []any, error) {
|
||||
needed := neededMatchedAliases(orders, fp.havingExpr, orderableSet)
|
||||
selects := []string{"trace_id"}
|
||||
for _, rc := range resolved {
|
||||
if _, ok := needed[rc.alias]; !ok {
|
||||
@@ -555,8 +511,22 @@ func (b *scopedTraceStatementBuilder) buildMatchedCTE(sb *sqlbuilder.SelectBuild
|
||||
having = append(having, "countIf("+maskExpr+") > 0")
|
||||
having = append(having, "countIf("+fp.spanPred+") > 0")
|
||||
}
|
||||
if fp.having != nil {
|
||||
having = append(having, fp.having.pred)
|
||||
if strings.TrimSpace(fp.havingExpr) != "" {
|
||||
// the rewriter matches raw key text, so map the trace. form alongside the bare name
|
||||
columnMap := make(map[string]string, len(orderableSet)*2)
|
||||
for a := range orderableSet {
|
||||
columnMap[a] = quoteAlias(a)
|
||||
columnMap[telemetrytypes.FieldContextTrace.StringValue()+"."+a] = quoteAlias(a)
|
||||
}
|
||||
hv, err := querybuilder.NewHavingExpressionRewriter().Rewrite(fp.havingExpr, columnMap)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if hv != "" {
|
||||
// escape user text so a literal $ isn't read as an arg marker; the countIf
|
||||
// entries hold live $n markers and must stay unescaped
|
||||
having = append(having, sqlbuilder.Escape(hv))
|
||||
}
|
||||
}
|
||||
if len(having) > 0 {
|
||||
sb.Having(strings.Join(having, " AND "))
|
||||
@@ -569,7 +539,7 @@ func (b *scopedTraceStatementBuilder) buildMatchedCTE(sb *sqlbuilder.SelectBuild
|
||||
}
|
||||
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return fmt.Sprintf("matched AS (%s)", sql), args
|
||||
return fmt.Sprintf("matched AS (%s)", sql), args, nil
|
||||
}
|
||||
|
||||
// buildRankedCTE builds `ranked`: [start,end] bounds per matched trace from the
|
||||
@@ -610,9 +580,8 @@ func (b *scopedTraceStatementBuilder) buildEnrichmentSelect(sb *sqlbuilder.Selec
|
||||
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
}
|
||||
|
||||
// aggregateAliasSet recognises trace-level keys — display-only aliases included, so one
|
||||
// gets a targeted error instead of falling through as a span attribute (what a predicate
|
||||
// may actually use is orderableColumnSet). SpanLevel columns are filtered span-level.
|
||||
// aggregateAliasSet is every trace-level column alias, used to classify filter keys;
|
||||
// SpanLevel columns are filtered span-level, so skip them.
|
||||
func (b *scopedTraceStatementBuilder) aggregateAliasSet() map[string]struct{} {
|
||||
set := make(map[string]struct{}, len(b.scope.Columns))
|
||||
for _, c := range b.scope.Columns {
|
||||
@@ -623,36 +592,59 @@ func (b *scopedTraceStatementBuilder) aggregateAliasSet() map[string]struct{} {
|
||||
return set
|
||||
}
|
||||
|
||||
// orderableAliasSet is the subset of aliases computable in the matched pass.
|
||||
func orderableAliasSet(resolved []resolvedColumn) map[string]struct{} {
|
||||
set := make(map[string]struct{})
|
||||
for _, rc := range resolved {
|
||||
if rc.orderable {
|
||||
set[rc.alias] = struct{}{}
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// neededMatchedAliases is the minimal alias set the matched pass must select: those
|
||||
// in ORDER BY plus those the resolved trace-level HAVING touches.
|
||||
func neededMatchedAliases(orders []listOrder, having *traceHaving) map[string]struct{} {
|
||||
// in ORDER BY plus those in the aggregate HAVING.
|
||||
func neededMatchedAliases(orders []listOrder, havingExpr string, orderableSet map[string]struct{}) map[string]struct{} {
|
||||
needed := make(map[string]struct{})
|
||||
for _, o := range orders {
|
||||
needed[o.alias] = struct{}{}
|
||||
}
|
||||
if having != nil {
|
||||
for name := range having.used {
|
||||
for _, name := range traceAggregateNames(havingExpr) {
|
||||
if _, ok := orderableSet[name]; ok {
|
||||
needed[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
return needed
|
||||
}
|
||||
|
||||
// validateAggregateFilter rejects filters on aggregates not computable in the matched
|
||||
// pass (e.g. span_count) upfront, since inside the where-clause visitor the error would
|
||||
// surface only as a detail of a combined one. Only unspecified- and trace-context
|
||||
// selectors name aggregates.
|
||||
// traceAggregateNames extracts the aggregate names a trace-level HAVING references;
|
||||
// only unspecified- and trace-context selectors name aggregates.
|
||||
func traceAggregateNames(havingExpr string) []string {
|
||||
var names []string
|
||||
for _, sel := range querybuilder.QueryStringToKeysSelectors(havingExpr) {
|
||||
if sel.FieldContext == telemetrytypes.FieldContextUnspecified || sel.FieldContext == telemetrytypes.FieldContextTrace {
|
||||
names = append(names, sel.Name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// validateAggregateFilter rejects a trace-level filter referencing an aggregate not
|
||||
// computable in the matched pass.
|
||||
func validateAggregateFilter(havingExpr string, orderableSet map[string]struct{}) error {
|
||||
if strings.TrimSpace(havingExpr) == "" {
|
||||
return nil
|
||||
}
|
||||
for _, sel := range querybuilder.QueryStringToKeysSelectors(havingExpr) {
|
||||
if sel.FieldContext != telemetrytypes.FieldContextUnspecified && sel.FieldContext != telemetrytypes.FieldContextTrace {
|
||||
continue
|
||||
}
|
||||
if _, ok := orderableSet[sel.Name]; !ok {
|
||||
allowed := make([]string, 0, len(orderableSet))
|
||||
for a := range orderableSet {
|
||||
allowed = append(allowed, a)
|
||||
}
|
||||
sort.Strings(allowed)
|
||||
for _, name := range traceAggregateNames(havingExpr) {
|
||||
if _, ok := orderableSet[name]; !ok {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"aggregate %q cannot be used in a trace-level filter; filterable aggregates: %s", sel.Name, strings.Join(sortedAliases(orderableSet), ", "))
|
||||
"aggregate %q cannot be used in the trace-list filter; filterable aggregates: %s", name, strings.Join(allowed, ", "))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -1,791 +0,0 @@
|
||||
package scopedtracesstatementbuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
chparser "github.com/AfterShip/clickhouse-sql-parser/parser"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// The per-trace values these aggregations read are window-clipped and span-filtered,
|
||||
// unlike the list's enrichment pass over every span of the whole trace, so the same
|
||||
// column reads differently in each.
|
||||
|
||||
// traceAggregation is one aggregation rewritten to run over the per-trace scan.
|
||||
type traceAggregation struct {
|
||||
expr string // rewritten SQL over the per-trace column aliases
|
||||
used map[string]struct{} // per-trace aliases referenced
|
||||
isRate bool
|
||||
}
|
||||
|
||||
// buildAggregation routes by aggregation domain: bare keys delegate to the standard
|
||||
// trace builder, trace.-prefixed aggregates run over the per-trace scan.
|
||||
func (b *scopedTraceStatementBuilder) buildAggregation(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end uint64,
|
||||
requestType qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
traceAggs, err := b.classifyAggregations(query.Aggregations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := b.validateGroupBy(query); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(traceAggs) == 0 {
|
||||
return b.buildDelegatedAggregation(ctx, orgID, start, end, requestType, query, variables)
|
||||
}
|
||||
return b.buildTraceAggregationQuery(ctx, orgID, querybuilder.ToNanoSecs(start), querybuilder.ToNanoSecs(end), requestType, query, variables, traceAggs)
|
||||
}
|
||||
|
||||
// classifyAggregations returns the rewritten trace-domain aggregations, nil when all
|
||||
// are span-domain; mixing the two domains is rejected.
|
||||
func (b *scopedTraceStatementBuilder) classifyAggregations(aggs []qbtypes.TraceAggregation) ([]traceAggregation, error) {
|
||||
// permission, not recognition: unknown names are reported against exactly this set
|
||||
traceCols := b.orderableColumnSet()
|
||||
var out []traceAggregation
|
||||
spanCount := 0
|
||||
for _, agg := range aggs {
|
||||
ta, isTrace, err := rewriteTraceAggregation(agg.Expression, traceCols)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isTrace {
|
||||
out = append(out, *ta)
|
||||
} else {
|
||||
spanCount++
|
||||
}
|
||||
}
|
||||
if len(out) > 0 && spanCount > 0 {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"span-level and trace-level (trace.) aggregations cannot be mixed in one query")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// orderableColumnSet is what a trace-level aggregation or filter predicate may use;
|
||||
// recognising a key as trace-level is aggregateAliasSet's job.
|
||||
func (b *scopedTraceStatementBuilder) orderableColumnSet() map[string]struct{} {
|
||||
set := make(map[string]struct{})
|
||||
for _, c := range b.scope.Columns {
|
||||
if c.Orderable {
|
||||
set[c.Alias] = struct{}{}
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// validateGroupBy rejects trace-level columns as group-by keys with a targeted error
|
||||
// (not the field mapper's generic "field not found"). Order keys need no check here:
|
||||
// request validation only admits group keys and aggregation aliases/expressions.
|
||||
func (b *scopedTraceStatementBuilder) validateGroupBy(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) error {
|
||||
// recognition, not permission: a display-only alias must be named here to be rejected
|
||||
// rather than reaching the field mapper as a span attribute
|
||||
aliases := b.aggregateAliasSet()
|
||||
for _, gb := range query.GroupBy {
|
||||
key := gb.TelemetryFieldKey
|
||||
key.Normalize()
|
||||
// a bare name may be a span column sharing the alias (duration_nano, timestamp)
|
||||
if key.FieldContext != telemetrytypes.FieldContextTrace {
|
||||
continue
|
||||
}
|
||||
if _, ok := aliases[key.Name]; ok {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"grouping by trace-level aggregate %q is not supported; group by span attributes instead (e.g. service.name)", gb.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rewriteTraceAggregation rewrites an aggregation over trace.-prefixed columns to run
|
||||
// on the per-trace scan (trace.output_tokens → output_tokens, functions mapped via
|
||||
// AggreFuncMap); a pure span-level expression returns isTrace=false for the delegate.
|
||||
func rewriteTraceAggregation(expr string, traceCols map[string]struct{}) (*traceAggregation, bool, error) {
|
||||
p := chparser.NewParser("SELECT " + expr)
|
||||
stmts, err := p.ParseStmts()
|
||||
if err != nil {
|
||||
return nil, false, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to parse aggregation expression %q", expr)
|
||||
}
|
||||
if len(stmts) == 0 {
|
||||
return nil, false, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid aggregation expression %q", expr)
|
||||
}
|
||||
sel, ok := stmts[0].(*chparser.SelectQuery)
|
||||
if !ok || len(sel.SelectItems) == 0 {
|
||||
return nil, false, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid aggregation expression %q", expr)
|
||||
}
|
||||
|
||||
v := &traceAggVisitor{traceCols: traceCols, used: make(map[string]struct{})}
|
||||
if err := sel.SelectItems[0].Accept(v); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !v.hasTrace {
|
||||
return nil, false, nil
|
||||
}
|
||||
if v.hasSpan {
|
||||
return nil, false, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"aggregation %q mixes trace-level (trace.) and span-level columns; use one domain per aggregation", expr)
|
||||
}
|
||||
// the interval divides the rendered expression as a whole, so a second aggregation
|
||||
// alongside the rate would be divided too
|
||||
if v.isRate && v.aggCount > 1 {
|
||||
return nil, false, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"aggregation %q combines a rate with another aggregation; the rate interval would divide both, so give each its own aggregation", expr)
|
||||
}
|
||||
return &traceAggregation{expr: chparser.Format(sel.SelectItems[0]), used: v.used, isRate: v.isRate}, true, nil
|
||||
}
|
||||
|
||||
// traceAggVisitor classifies column references and rewrites trace.-prefixed ones in
|
||||
// place; the ancestor stack tells a column identifier from a path segment, function
|
||||
// name, or alias, and rejects trace. columns inside *If combinators.
|
||||
type traceAggVisitor struct {
|
||||
chparser.DefaultASTVisitor
|
||||
traceCols map[string]struct{}
|
||||
used map[string]struct{}
|
||||
stack []chparser.Expr
|
||||
aggCount int
|
||||
hasTrace bool
|
||||
hasSpan bool
|
||||
isRate bool
|
||||
}
|
||||
|
||||
func (v *traceAggVisitor) Enter(expr chparser.Expr) { v.stack = append(v.stack, expr) }
|
||||
func (v *traceAggVisitor) Leave(expr chparser.Expr) { v.stack = v.stack[:len(v.stack)-1] }
|
||||
|
||||
// parent is the node enclosing the one currently being visited (the visited node
|
||||
// itself is the stack top).
|
||||
func (v *traceAggVisitor) parent() chparser.Expr {
|
||||
if len(v.stack) < 2 {
|
||||
return nil
|
||||
}
|
||||
return v.stack[len(v.stack)-2]
|
||||
}
|
||||
|
||||
func (v *traceAggVisitor) enclosingCombinator() (string, bool) {
|
||||
for _, e := range v.stack {
|
||||
fn, ok := e.(*chparser.FunctionExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if agg, known := querybuilder.AggreFuncMap[valuer.NewString(strings.ToLower(fn.Name.Name))]; known && agg.FuncCombinator {
|
||||
return fn.Name.Name, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// enclosingAggregate walks the ancestor stack; AggreFuncMap holds only aggregates and
|
||||
// VisitFunctionExpr rejects any name missing from it, so a known name is enough.
|
||||
func (v *traceAggVisitor) enclosingAggregate() bool {
|
||||
for _, e := range v.stack {
|
||||
fn, ok := e.(*chparser.FunctionExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, known := querybuilder.AggreFuncMap[valuer.NewString(strings.ToLower(fn.Name.Name))]; known {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// VisitPath classifies a dotted reference (trace.output_tokens); trace-level ones are
|
||||
// rewritten in place to the bare per-trace alias.
|
||||
func (v *traceAggVisitor) VisitPath(p *chparser.Path) error {
|
||||
col, isTrace := traceColumnFromPath(p)
|
||||
if !isTrace {
|
||||
v.hasSpan = true
|
||||
return nil
|
||||
}
|
||||
if err := v.acceptTraceColumn(chparser.Format(p), col); err != nil {
|
||||
return err
|
||||
}
|
||||
p.Fields = p.Fields[len(p.Fields)-1:]
|
||||
p.Fields[0].Name = col
|
||||
return nil
|
||||
}
|
||||
|
||||
// VisitIdent classifies a plain identifier (a backquoted `trace.output_tokens` is
|
||||
// trace-level); path segments, function names, and aliases are structural, not columns.
|
||||
func (v *traceAggVisitor) VisitIdent(i *chparser.Ident) error {
|
||||
switch parent := v.parent().(type) {
|
||||
case *chparser.Path:
|
||||
return nil // segments are classified whole by VisitPath
|
||||
case *chparser.FunctionExpr:
|
||||
if parent.Name == i {
|
||||
return nil
|
||||
}
|
||||
case *chparser.ColumnExpr:
|
||||
if parent.Alias == i {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// the parser hands us one identifier, so the prefix is the only text to cut here
|
||||
col, isTrace := strings.CutPrefix(i.Name, telemetrytypes.FieldContextTrace.StringValue()+".")
|
||||
if !isTrace || col == "" {
|
||||
v.hasSpan = true
|
||||
return nil
|
||||
}
|
||||
if err := v.acceptTraceColumn(i.Name, col); err != nil {
|
||||
return err
|
||||
}
|
||||
i.Name = col
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *traceAggVisitor) acceptTraceColumn(ref, col string) error {
|
||||
if name, in := v.enclosingCombinator(); in {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"%q over trace-level (trace.) columns is not supported; put the trace-level condition in the filter expression instead", name)
|
||||
}
|
||||
// trace_id is always selected by the per-trace scan (count(trace.trace_id)
|
||||
// counts traces); everything else must be a scope column.
|
||||
if col != "trace_id" {
|
||||
if _, known := v.traceCols[col]; !known {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"unknown trace-level aggregation column %q; usable columns: %s", ref, strings.Join(sortedAliases(v.traceCols), ", "))
|
||||
}
|
||||
v.used[col] = struct{}{}
|
||||
}
|
||||
// ungrouped, a bare per-trace column would make the outer SELECT emit one row per
|
||||
// trace instead of one aggregated row
|
||||
if !v.enclosingAggregate() {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"trace-level column %q must be inside an aggregation function (e.g. avg(%s))", ref, ref)
|
||||
}
|
||||
v.hasTrace = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// VisitFunctionExpr validates and maps the function name. Children were already
|
||||
// visited (post-order), so classification is complete for this subtree.
|
||||
func (v *traceAggVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
|
||||
name := strings.ToLower(fn.Name.Name)
|
||||
aggFunc, ok := querybuilder.AggreFuncMap[valuer.NewString(name)]
|
||||
if !ok {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unrecognized function: %s", name)
|
||||
}
|
||||
if fn.Params != nil && fn.Params.Items != nil && len(fn.Params.Items.Items) > 0 && aggFunc.FuncCombinator {
|
||||
// combinator predicates over span columns stay span-level (countIf(has_error=true))
|
||||
v.hasSpan = true
|
||||
return nil
|
||||
}
|
||||
fn.Name.Name = aggFunc.FuncName
|
||||
v.aggCount++
|
||||
if aggFunc.Rate {
|
||||
v.isRate = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// traceColumnFromPath returns the per-trace column a dotted reference names
|
||||
// (trace.output_tokens -> output_tokens, trace.a.b -> a.b).
|
||||
func traceColumnFromPath(p *chparser.Path) (string, bool) {
|
||||
if len(p.Fields) < 2 || p.Fields[0].Name != telemetrytypes.FieldContextTrace.StringValue() {
|
||||
return "", false
|
||||
}
|
||||
segments := make([]string, 0, len(p.Fields)-1)
|
||||
for _, f := range p.Fields[1:] {
|
||||
segments = append(segments, f.Name)
|
||||
}
|
||||
return strings.Join(segments, "."), true
|
||||
}
|
||||
|
||||
func sortedAliases(set map[string]struct{}) []string {
|
||||
out := make([]string, 0, len(set))
|
||||
for a := range set {
|
||||
out = append(out, a)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Qualification + per-trace scan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// buildQualifiedStatement selects the trace ids whose window-clipped aggregates satisfy
|
||||
// the trace-level filter, with the resource prune inlined since the caller embeds this
|
||||
// standalone. start/end are ns; nil when variable resolution dropped every condition.
|
||||
func (b *scopedTraceStatementBuilder) buildQualifiedStatement(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end uint64,
|
||||
traceExpr string,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
keys, err := b.fetchKeys(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
maskExpr, resolved, err := b.resolveFor(ctx, orgID, start, end, keys, sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
having, err := b.resolveTraceHaving(ctx, traceExpr, variables, sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if having == nil {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
var resourcePred string
|
||||
// nil when the filter has no resource-attribute conditions
|
||||
if stmt, err := b.resourceFilterStmtBuilder.Build(ctx, orgID, start, end, qbtypes.RequestTypeRaw, query, variables); err != nil {
|
||||
return nil, err
|
||||
} else if stmt != nil {
|
||||
inlined, err := embedExpr(sb, stmt.Query, stmt.Args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resourcePred = fmt.Sprintf("resource_fingerprint GLOBAL IN (SELECT fingerprint FROM (%s))", inlined)
|
||||
}
|
||||
sql, args := b.buildPerTraceScan(sb, start, end, resolved, maskExpr, perTraceScanOpts{
|
||||
needed: having.used,
|
||||
havingPred: having.pred,
|
||||
resourcePred: resourcePred,
|
||||
})
|
||||
return &qbtypes.Statement{Query: sql, Args: args}, nil
|
||||
}
|
||||
|
||||
// embedExpr inlines a pre-built statement into sb, replacing each `?` with a builder
|
||||
// Var; a count mismatch would silently shift args into the wrong slots, so error out.
|
||||
func embedExpr(sb *sqlbuilder.SelectBuilder, expr string, args []any) (string, error) {
|
||||
if n := strings.Count(expr, "?"); n != len(args) {
|
||||
return "", errors.NewInternalf(errors.CodeInternal,
|
||||
"scoped trace builder: %d placeholders != %d args embedding %q", n, len(args), expr)
|
||||
}
|
||||
var out strings.Builder
|
||||
ai := 0
|
||||
for i := 0; i < len(expr); i++ {
|
||||
if expr[i] == '?' {
|
||||
out.WriteString(sb.Var(args[ai]))
|
||||
ai++
|
||||
continue
|
||||
}
|
||||
out.WriteByte(expr[i])
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
// groupColumn holds a resolved, arg-free span-attribute expression.
|
||||
type groupColumn struct {
|
||||
alias string
|
||||
expr string
|
||||
}
|
||||
|
||||
// groupByColumnAlias prefixes the i-th group-by dimension so the alias cannot shadow the
|
||||
// span column its expression reads; the querier (stripKeyAlias) strips it back off.
|
||||
func groupByColumnAlias(i int, name string) string {
|
||||
return fmt.Sprintf("__GROUP_BY_KEY_%d_%s", i, name)
|
||||
}
|
||||
|
||||
// orderColumn is the SQL identifier a non-aggregation order key sorts by: the
|
||||
// positional alias when the key names a group-by dimension, else the key itself.
|
||||
func orderColumn(orderKey string, groupBy []qbtypes.GroupByKey) string {
|
||||
for i := range groupBy {
|
||||
if groupBy[i].Name == orderKey {
|
||||
return groupByColumnAlias(i, groupBy[i].Name)
|
||||
}
|
||||
}
|
||||
return orderKey
|
||||
}
|
||||
|
||||
// perTraceScanOpts parametrize one windowed, mask-pruned GROUP BY trace_id scan.
|
||||
// All expressions are already resolved against the scan's builder.
|
||||
type perTraceScanOpts struct {
|
||||
stepSeconds int64 // >0 → bucket per-trace values by time (ts column)
|
||||
groupCols []groupColumn
|
||||
needed map[string]struct{} // per-trace aliases to select
|
||||
spanPred string // resolved span-level filter, ANDed per span
|
||||
resourcePred string // resource-fingerprint prune (CTE reference or inline subquery)
|
||||
qualified bool // constrain to __qualified
|
||||
limitPred string // top-N group prune (GLOBAL IN __limit_cte)
|
||||
havingPred string // resolved HAVING predicate over the selected aliases
|
||||
}
|
||||
|
||||
func (b *scopedTraceStatementBuilder) buildPerTraceScan(sb *sqlbuilder.SelectBuilder, start, end uint64, resolved []resolvedColumn, maskExpr string, o perTraceScanOpts) (string, []any) {
|
||||
startBucket := start/querybuilder.NsToSeconds - querybuilder.BucketAdjustment
|
||||
endBucket := end / querybuilder.NsToSeconds
|
||||
|
||||
selects := []string{"trace_id"}
|
||||
if o.stepSeconds > 0 {
|
||||
selects = append(selects, fmt.Sprintf("toStartOfInterval(timestamp, INTERVAL %d SECOND) AS ts", o.stepSeconds))
|
||||
}
|
||||
for _, gc := range o.groupCols {
|
||||
selects = append(selects, fmt.Sprintf("toString(%s) AS `%s`", gc.expr, gc.alias))
|
||||
}
|
||||
for _, rc := range resolved {
|
||||
if _, ok := o.needed[rc.alias]; !ok {
|
||||
continue
|
||||
}
|
||||
selects = append(selects, rc.expr+" AS "+quoteAlias(rc.alias))
|
||||
}
|
||||
sb.Select(selects...)
|
||||
sb.From(fmt.Sprintf("%s.%s", tracestelemetryschema.DBName, tracestelemetryschema.SpanIndexV3TableName))
|
||||
|
||||
where := []string{
|
||||
sb.GE("timestamp", fmt.Sprintf("%d", start)),
|
||||
sb.L("timestamp", fmt.Sprintf("%d", end)),
|
||||
sb.GE("ts_bucket_start", startBucket),
|
||||
sb.LE("ts_bucket_start", endBucket),
|
||||
maskExpr,
|
||||
}
|
||||
if strings.TrimSpace(o.spanPred) != "" {
|
||||
where = append(where, o.spanPred)
|
||||
}
|
||||
if o.resourcePred != "" {
|
||||
where = append(where, o.resourcePred)
|
||||
}
|
||||
if o.qualified {
|
||||
where = append(where, "trace_id GLOBAL IN (SELECT trace_id FROM __qualified)")
|
||||
}
|
||||
if o.limitPred != "" {
|
||||
where = append(where, o.limitPred)
|
||||
}
|
||||
sb.Where(where...)
|
||||
|
||||
groupBy := []string{"trace_id"}
|
||||
if o.stepSeconds > 0 {
|
||||
groupBy = append(groupBy, "ts")
|
||||
}
|
||||
for _, gc := range o.groupCols {
|
||||
groupBy = append(groupBy, "`"+gc.alias+"`")
|
||||
}
|
||||
sb.GroupBy(groupBy...)
|
||||
if strings.TrimSpace(o.havingPred) != "" {
|
||||
sb.Having(o.havingPred)
|
||||
}
|
||||
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
}
|
||||
|
||||
// resolveGroupColumns resolves group-by keys through the field mapper, which needs the
|
||||
// metadata keys, for selection inside the per-trace scan.
|
||||
func (b *scopedTraceStatementBuilder) resolveGroupColumns(ctx context.Context, orgID valuer.UUID, start, end uint64, groupBy []qbtypes.GroupByKey) ([]groupColumn, error) {
|
||||
if len(groupBy) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
selectors := make([]*telemetrytypes.FieldKeySelector, 0, len(groupBy))
|
||||
for i := range groupBy {
|
||||
selectors = append(selectors, &telemetrytypes.FieldKeySelector{
|
||||
Name: groupBy[i].Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: groupBy[i].FieldContext,
|
||||
FieldDataType: groupBy[i].FieldDataType,
|
||||
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact,
|
||||
})
|
||||
}
|
||||
keys, _, err := b.metadataStore.GetKeysMulti(ctx, orgID, selectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]groupColumn, 0, len(groupBy))
|
||||
for i := range groupBy {
|
||||
expr, err := b.fm.ColumnExpressionFor(ctx, orgID, start, end, &groupBy[i].TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, groupColumn{alias: groupByColumnAlias(i, groupBy[i].Name), expr: sqlbuilder.Escape(expr)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Native trace-domain aggregation query
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// scanContext is one per-scan resolution: a fresh builder with the mask, columns,
|
||||
// span predicate, and optionally the trace-level HAVING resolved against it.
|
||||
type scanContext struct {
|
||||
sb *sqlbuilder.SelectBuilder
|
||||
maskExpr string
|
||||
resolved []resolvedColumn
|
||||
spanPred string
|
||||
having *traceHaving
|
||||
warnings []string
|
||||
warnURL string
|
||||
}
|
||||
|
||||
func (b *scopedTraceStatementBuilder) newScanContext(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end uint64,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
spanExpr, traceExpr string,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*scanContext, error) {
|
||||
sc := &scanContext{sb: sqlbuilder.NewSelectBuilder()}
|
||||
var err error
|
||||
sc.maskExpr, sc.resolved, err = b.resolveFor(ctx, orgID, start, end, keys, sc.sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(spanExpr) != "" {
|
||||
pred, warns, url, err := b.resolveSpanPredicate(ctx, orgID, start, end, spanExpr, variables, sc.sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sc.spanPred, sc.warnings, sc.warnURL = pred, warns, url
|
||||
}
|
||||
if strings.TrimSpace(traceExpr) != "" {
|
||||
sc.having, err = b.resolveTraceHaving(ctx, traceExpr, variables, sc.sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return sc, nil
|
||||
}
|
||||
|
||||
// buildTraceAggregationQuery aggregates over the per-trace scan: __qualified (when the
|
||||
// filter has a trace-level part) → __scoped_traces → outer aggregation. start/end are ns.
|
||||
func (b *scopedTraceStatementBuilder) buildTraceAggregationQuery(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end uint64,
|
||||
requestType qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
traceAggs []traceAggregation,
|
||||
) (*qbtypes.Statement, error) {
|
||||
keys, err := b.fetchKeys(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var spanExpr, traceExpr string
|
||||
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
|
||||
// the broad set so a condition on a display-only alias still lands in the
|
||||
// trace-level part, where resolveTraceHaving rejects it by name
|
||||
spanExpr, traceExpr, err = querybuilder.SplitFilterForAggregates(query.Filter.Expression, b.aggregateAliasSet())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
resourceFrag, resourceArgs, resourcePred, err := b.maybeAttachResourceFilter(ctx, orgID, query, start, end, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cteFragments []string
|
||||
var cteArgs [][]any
|
||||
if resourceFrag != "" {
|
||||
cteFragments = append(cteFragments, resourceFrag)
|
||||
cteArgs = append(cteArgs, resourceArgs)
|
||||
}
|
||||
|
||||
// __qualified: its own scan resolution, HAVING = the trace-level filter part
|
||||
qualified := false
|
||||
if strings.TrimSpace(traceExpr) != "" {
|
||||
qsc, err := b.newScanContext(ctx, orgID, start, end, keys, "", traceExpr, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if qsc.having != nil {
|
||||
qsql, qargs := b.buildPerTraceScan(qsc.sb, start, end, qsc.resolved, qsc.maskExpr, perTraceScanOpts{
|
||||
needed: qsc.having.used,
|
||||
havingPred: qsc.having.pred,
|
||||
resourcePred: resourcePred,
|
||||
})
|
||||
cteFragments = append(cteFragments, fmt.Sprintf("__qualified AS (%s)", qsql))
|
||||
cteArgs = append(cteArgs, qargs)
|
||||
qualified = true
|
||||
}
|
||||
}
|
||||
|
||||
groupCols, err := b.resolveGroupColumns(ctx, orgID, start, end, query.GroupBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groupNames := make([]string, 0, len(groupCols))
|
||||
for _, gc := range groupCols {
|
||||
groupNames = append(groupNames, "`"+gc.alias+"`")
|
||||
}
|
||||
|
||||
needed := make(map[string]struct{})
|
||||
for _, ta := range traceAggs {
|
||||
for a := range ta.used {
|
||||
needed[a] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
stepSeconds := int64(0)
|
||||
rateInterval := (end - start) / querybuilder.NsToSeconds
|
||||
if requestType == qbtypes.RequestTypeTimeSeries {
|
||||
stepSeconds = int64(query.StepInterval.Seconds())
|
||||
rateInterval = uint64(stepSeconds)
|
||||
}
|
||||
|
||||
// outer aggregation over the per-trace rows
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
selects := []string{}
|
||||
if stepSeconds > 0 {
|
||||
selects = append(selects, "ts")
|
||||
}
|
||||
selects = append(selects, groupNames...)
|
||||
for i, ta := range traceAggs {
|
||||
selects = append(selects, fmt.Sprintf("%s AS __result_%d", ta.rendered(rateInterval), i))
|
||||
}
|
||||
sb.Select(selects...)
|
||||
sb.From("__scoped_traces")
|
||||
|
||||
// grouped, limited time series → rank groups on whole-window per-trace values
|
||||
// (exact for non-composable aggregates) and prune the main scan to the top-N.
|
||||
limitPred := ""
|
||||
if requestType == qbtypes.RequestTypeTimeSeries && query.Limit > 0 && len(groupCols) > 0 {
|
||||
tsc, err := b.newScanContext(ctx, orgID, start, end, keys, spanExpr, "", variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalSQL, totalArgs := b.buildPerTraceScan(tsc.sb, start, end, tsc.resolved, tsc.maskExpr, perTraceScanOpts{
|
||||
groupCols: groupCols,
|
||||
needed: needed,
|
||||
spanPred: tsc.spanPred,
|
||||
resourcePred: resourcePred,
|
||||
qualified: qualified,
|
||||
})
|
||||
cteFragments = append(cteFragments, fmt.Sprintf("__scoped_traces_total AS (%s)", totalSQL))
|
||||
cteArgs = append(cteArgs, totalArgs)
|
||||
|
||||
limitSQL, limitArgs := outerLimitSQL(query, traceAggs, groupNames, (end-start)/querybuilder.NsToSeconds)
|
||||
cteFragments = append(cteFragments, fmt.Sprintf("__limit_cte AS (%s)", limitSQL))
|
||||
cteArgs = append(cteArgs, limitArgs)
|
||||
|
||||
exprs := make([]string, 0, len(groupCols))
|
||||
for _, gc := range groupCols {
|
||||
exprs = append(exprs, "toString("+gc.expr+")")
|
||||
}
|
||||
limitPred = fmt.Sprintf("(%s) GLOBAL IN (SELECT %s FROM __limit_cte)",
|
||||
strings.Join(exprs, ", "), strings.Join(groupNames, ", "))
|
||||
}
|
||||
|
||||
msc, err := b.newScanContext(ctx, orgID, start, end, keys, spanExpr, "", variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
perTraceSQL, perTraceArgs := b.buildPerTraceScan(msc.sb, start, end, msc.resolved, msc.maskExpr, perTraceScanOpts{
|
||||
stepSeconds: stepSeconds,
|
||||
groupCols: groupCols,
|
||||
needed: needed,
|
||||
spanPred: msc.spanPred,
|
||||
resourcePred: resourcePred,
|
||||
qualified: qualified,
|
||||
limitPred: limitPred,
|
||||
})
|
||||
cteFragments = append(cteFragments, fmt.Sprintf("__scoped_traces AS (%s)", perTraceSQL))
|
||||
cteArgs = append(cteArgs, perTraceArgs)
|
||||
|
||||
groupBys := []string{}
|
||||
if stepSeconds > 0 {
|
||||
groupBys = append(groupBys, "ts")
|
||||
}
|
||||
groupBys = append(groupBys, groupNames...)
|
||||
if len(groupBys) > 0 {
|
||||
sb.GroupBy(groupBys...)
|
||||
}
|
||||
|
||||
if query.Having != nil && strings.TrimSpace(query.Having.Expression) != "" {
|
||||
rewritten, err := querybuilder.NewHavingExpressionRewriter().RewriteForTraces(query.Having.Expression, query.Aggregations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sb.Having(sqlbuilder.Escape(rewritten))
|
||||
}
|
||||
|
||||
if requestType == qbtypes.RequestTypeTimeSeries {
|
||||
if len(query.Order) != 0 {
|
||||
for _, orderBy := range query.Order {
|
||||
if _, ok := traceAggOrderIndex(orderBy, query); !ok {
|
||||
sb.OrderBy(fmt.Sprintf("`%s` %s", orderColumn(orderBy.Key.Name, query.GroupBy), orderBy.Direction.StringValue()))
|
||||
}
|
||||
}
|
||||
sb.OrderBy("ts desc")
|
||||
}
|
||||
} else {
|
||||
for _, orderBy := range query.Order {
|
||||
if idx, ok := traceAggOrderIndex(orderBy, query); ok {
|
||||
sb.OrderBy(fmt.Sprintf("__result_%d %s", idx, orderBy.Direction.StringValue()))
|
||||
} else {
|
||||
sb.OrderBy(fmt.Sprintf("`%s` %s", orderColumn(orderBy.Key.Name, query.GroupBy), orderBy.Direction.StringValue()))
|
||||
}
|
||||
}
|
||||
if len(query.Order) == 0 {
|
||||
sb.OrderBy("__result_0 DESC")
|
||||
}
|
||||
if query.Limit > 0 {
|
||||
sb.Limit(query.Limit)
|
||||
}
|
||||
}
|
||||
|
||||
mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
finalSQL := querybuilder.CombineCTEs(cteFragments) + mainSQL + " SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000"
|
||||
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
|
||||
|
||||
return &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: msc.warnings,
|
||||
WarningsDocURL: msc.warnURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// rendered divides a rate aggregation by the interval (step for time series, window
|
||||
// length for scalar); the divisor applies to the whole expression, which holds only
|
||||
// because a rate must be the sole aggregation.
|
||||
func (ta traceAggregation) rendered(rateInterval uint64) string {
|
||||
if ta.isRate {
|
||||
return fmt.Sprintf("%s/%d", ta.expr, rateInterval)
|
||||
}
|
||||
return ta.expr
|
||||
}
|
||||
|
||||
// outerLimitSQL ranks groups on whole-window per-trace values, so a non-composable
|
||||
// aggregate (avg) ranks exactly rather than over bucketed rows.
|
||||
func outerLimitSQL(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], traceAggs []traceAggregation, groupNames []string, windowSeconds uint64) (string, []any) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
selects := append([]string{}, groupNames...)
|
||||
for i, ta := range traceAggs {
|
||||
selects = append(selects, fmt.Sprintf("%s AS __result_%d", ta.rendered(windowSeconds), i))
|
||||
}
|
||||
sb.Select(selects...)
|
||||
sb.From("__scoped_traces_total")
|
||||
sb.GroupBy(groupNames...)
|
||||
for _, orderBy := range query.Order {
|
||||
if idx, ok := traceAggOrderIndex(orderBy, query); ok {
|
||||
sb.OrderBy(fmt.Sprintf("__result_%d %s", idx, orderBy.Direction.StringValue()))
|
||||
} else {
|
||||
sb.OrderBy(fmt.Sprintf("`%s` %s", orderColumn(orderBy.Key.Name, query.GroupBy), orderBy.Direction.StringValue()))
|
||||
}
|
||||
}
|
||||
if len(query.Order) == 0 {
|
||||
sb.OrderBy("__result_0 DESC")
|
||||
}
|
||||
sb.Limit(query.Limit)
|
||||
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
}
|
||||
|
||||
// traceAggOrderIndex reports whether an order key refers to the i-th aggregation
|
||||
// (by alias, expression, or index), mirroring the trace builder.
|
||||
func traceAggOrderIndex(k qbtypes.OrderBy, q qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) (int, bool) {
|
||||
for i, agg := range q.Aggregations {
|
||||
if k.Key.Name == agg.Alias ||
|
||||
k.Key.Name == agg.Expression ||
|
||||
k.Key.Name == fmt.Sprintf("%d", i) {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package scopedtracesstatementbuilder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRewriteTraceAggregation(t *testing.T) {
|
||||
cols := map[string]struct{}{
|
||||
"input_tokens": {}, "output_tokens": {}, "total_tokens": {}, "llm_call_count": {}, "max_llm_latency_ns": {},
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
expr string
|
||||
isTrace bool
|
||||
want string // rewritten expr, only checked when isTrace
|
||||
used []string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "avg trace col", expr: "avg(trace.output_tokens)", isTrace: true, want: "avg(output_tokens)", used: []string{"output_tokens"}},
|
||||
{name: "sum trace col", expr: "sum(trace.total_tokens)", isTrace: true, want: "sum(total_tokens)", used: []string{"total_tokens"}},
|
||||
{name: "count traces", expr: "count(trace.trace_id)", isTrace: true, want: "count(trace_id)"},
|
||||
{name: "p90 trace col", expr: "p90(trace.max_llm_latency_ns)", isTrace: true, want: "quantile(0.90)(max_llm_latency_ns)", used: []string{"max_llm_latency_ns"}},
|
||||
{name: "arithmetic between trace cols", expr: "avg(trace.output_tokens + trace.input_tokens)", isTrace: true, want: "avg(output_tokens + input_tokens)", used: []string{"output_tokens", "input_tokens"}},
|
||||
{name: "arithmetic with constant", expr: "sum(trace.output_tokens * 1.5)", isTrace: true, want: "sum(output_tokens * 1.5)", used: []string{"output_tokens"}},
|
||||
{name: "ratio of two aggregations", expr: "sum(trace.output_tokens)/count(trace.trace_id)", isTrace: true, want: "sum(output_tokens) / count(trace_id)", used: []string{"output_tokens"}},
|
||||
{name: "backquoted trace col", expr: "avg(`trace.output_tokens`)", isTrace: true, want: "avg(`output_tokens`)", used: []string{"output_tokens"}},
|
||||
{name: "bare count is span-level", expr: "count()", isTrace: false},
|
||||
{name: "span attribute is span-level", expr: "sum(gen_ai.usage.output_tokens)", isTrace: false},
|
||||
{name: "countIf span predicate is span-level", expr: "countIf(has_error = true)", isTrace: false},
|
||||
{name: "mixed domains in one expression", expr: "sum(trace.output_tokens) + sum(gen_ai.usage.input_tokens)", wantErr: "mixes trace-level"},
|
||||
{name: "mixed domains in one function", expr: "sum(trace.output_tokens + gen_ai.usage.input_tokens)", wantErr: "mixes trace-level"},
|
||||
{name: "output-only column rejected", expr: "avg(trace.span_count)", wantErr: "unknown trace-level aggregation column"},
|
||||
{name: "unknown column rejected", expr: "avg(trace.bogus)", wantErr: "unknown trace-level aggregation column"},
|
||||
// a dotted column keeps every segment after the prefix, so it is reported whole
|
||||
{name: "multi segment column rejected by full name", expr: "avg(trace.service.name)", wantErr: `"trace.service.name"`},
|
||||
{name: "bare trace identifier is span-level", expr: "avg(trace)", isTrace: false},
|
||||
{name: "countIf over trace col rejected", expr: "countIf(trace.output_tokens > 1000)", wantErr: "not supported"},
|
||||
{name: "bare trace col rejected", expr: "trace.output_tokens", wantErr: "must be inside an aggregation function"},
|
||||
{name: "backquoted bare trace col rejected", expr: "`trace.output_tokens`", wantErr: "must be inside an aggregation function"},
|
||||
{name: "bare trace_id rejected", expr: "trace.trace_id", wantErr: "must be inside an aggregation function"},
|
||||
{name: "arithmetic outside an aggregation rejected", expr: "trace.output_tokens + trace.input_tokens", wantErr: "must be inside an aggregation function"},
|
||||
{name: "trace col beside an aggregation rejected", expr: "sum(trace.output_tokens) + trace.input_tokens", wantErr: "must be inside an aggregation function"},
|
||||
{name: "aggregation scaled by a constant", expr: "sum(trace.output_tokens) * 2", isTrace: true, want: "sum(output_tokens) * 2", used: []string{"output_tokens"}},
|
||||
{name: "rate over traces", expr: "rate(trace.trace_id)", isTrace: true, want: "count(trace_id)"},
|
||||
{name: "rate_sum trace col", expr: "rate_sum(trace.output_tokens)", isTrace: true, want: "sum(output_tokens)", used: []string{"output_tokens"}},
|
||||
// the interval divides the whole rendered expression, so a second aggregation
|
||||
// alongside a rate would be divided too
|
||||
{name: "rate mixed with another aggregation rejected", expr: "rate(trace.trace_id) + avg(trace.output_tokens)", wantErr: "combines a rate with another aggregation"},
|
||||
{name: "ratio of two rates rejected", expr: "rate_sum(trace.output_tokens)/rate_sum(trace.input_tokens)", wantErr: "combines a rate with another aggregation"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ta, isTrace, err := rewriteTraceAggregation(tc.expr, cols)
|
||||
if tc.wantErr != "" {
|
||||
require.ErrorContains(t, err, tc.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.isTrace, isTrace)
|
||||
if !tc.isTrace {
|
||||
return
|
||||
}
|
||||
assert.Equal(t, tc.want, ta.expr)
|
||||
for _, u := range tc.used {
|
||||
assert.Contains(t, ta.used, u)
|
||||
}
|
||||
assert.Len(t, ta.used, len(tc.used))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package scopedtracesstatementbuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
qbvariables "github.com/SigNoz/signoz/pkg/variables"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// traceHaving is the resolved trace-level filter part: a HAVING predicate over the
|
||||
// per-trace aliases plus the aliases it references (so scans select only those).
|
||||
type traceHaving struct {
|
||||
pred string
|
||||
used map[string]struct{}
|
||||
}
|
||||
|
||||
// resolveTraceHaving runs a trace-level filter through the standard where-clause
|
||||
// pipeline against the per-trace aliases, so operators, bound args, and __all__ behave
|
||||
// as in span filters. Returns nil when nothing is left to filter; args bind into sb.
|
||||
func (b *scopedTraceStatementBuilder) resolveTraceHaving(ctx context.Context, expr string, variables map[string]qbtypes.VariableItem, sb *sqlbuilder.SelectBuilder) (*traceHaving, error) {
|
||||
if strings.TrimSpace(expr) == "" {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
// replaced before validation so variable literals are not mistaken for aggregate
|
||||
// names; an unresolved $var is left in place and fails validation as an unknown one
|
||||
if len(variables) > 0 {
|
||||
replaced, err := qbvariables.ReplaceVariablesInExpression(expr, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expr = replaced
|
||||
if strings.TrimSpace(expr) == "" {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
}
|
||||
allowed := b.orderableColumnSet()
|
||||
// upfront targeted errors; the visitor folds them into a combined "Found N errors"
|
||||
if err := validateAggregateFilter(expr, allowed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// both spellings resolve here: the key parser strips the trace. prefix into
|
||||
// FieldContextTrace, which matches this entry's context
|
||||
fieldKeys := make(map[string][]*telemetrytypes.TelemetryFieldKey, len(allowed))
|
||||
for alias := range allowed {
|
||||
key := &telemetrytypes.TelemetryFieldKey{Name: alias, FieldContext: telemetrytypes.FieldContextTrace}
|
||||
fieldKeys[alias] = []*telemetrytypes.TelemetryFieldKey{key}
|
||||
}
|
||||
|
||||
cb := &aliasConditionBuilder{allowed: allowed, used: make(map[string]struct{})}
|
||||
prepared, err := querybuilder.PrepareWhereClause(expr, querybuilder.FilterExprVisitorOpts{
|
||||
Context: ctx,
|
||||
Logger: b.logger,
|
||||
ConditionBuilder: cb,
|
||||
FieldKeys: fieldKeys,
|
||||
Variables: variables,
|
||||
Builder: sb,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if prepared.IsEmpty() {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
return &traceHaving{pred: prepared.Expr, used: cb.used}, nil
|
||||
}
|
||||
|
||||
// aliasConditionBuilder renders filter conditions directly against the per-trace
|
||||
// aliases, recording the ones it touches; a key resolving to no alias is an error.
|
||||
type aliasConditionBuilder struct {
|
||||
allowed map[string]struct{}
|
||||
used map[string]struct{}
|
||||
}
|
||||
|
||||
var _ qbtypes.ConditionBuilder = (*aliasConditionBuilder)(nil)
|
||||
|
||||
func (c *aliasConditionBuilder) ConditionFor(
|
||||
_ context.Context,
|
||||
_ valuer.UUID,
|
||||
_, _ uint64,
|
||||
key *telemetrytypes.TelemetryFieldKey,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
_ qbtypes.ConditionBuilderOptions,
|
||||
op qbtypes.FilterOperator,
|
||||
value any,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
) ([]string, []string, error) {
|
||||
matching := keys[key.Name]
|
||||
if len(matching) == 0 {
|
||||
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"aggregate %q cannot be used in a trace-level filter; filterable aggregates: %s",
|
||||
key.Name, strings.Join(sortedAliases(c.allowed), ", "))
|
||||
}
|
||||
alias := matching[0].Name
|
||||
c.used[alias] = struct{}{}
|
||||
col := quoteAlias(alias)
|
||||
|
||||
var cond string
|
||||
switch op {
|
||||
case qbtypes.FilterOperatorEqual:
|
||||
cond = sb.E(col, value)
|
||||
case qbtypes.FilterOperatorNotEqual:
|
||||
cond = sb.NE(col, value)
|
||||
case qbtypes.FilterOperatorGreaterThan:
|
||||
cond = sb.G(col, value)
|
||||
case qbtypes.FilterOperatorGreaterThanOrEq:
|
||||
cond = sb.GE(col, value)
|
||||
case qbtypes.FilterOperatorLessThan:
|
||||
cond = sb.L(col, value)
|
||||
case qbtypes.FilterOperatorLessThanOrEq:
|
||||
cond = sb.LE(col, value)
|
||||
case qbtypes.FilterOperatorIn, qbtypes.FilterOperatorNotIn:
|
||||
values, ok := value.([]any)
|
||||
if !ok {
|
||||
values = []any{value}
|
||||
}
|
||||
if op == qbtypes.FilterOperatorIn {
|
||||
cond = sb.In(col, values...)
|
||||
} else {
|
||||
cond = sb.NotIn(col, values...)
|
||||
}
|
||||
case qbtypes.FilterOperatorBetween, qbtypes.FilterOperatorNotBetween:
|
||||
values, ok := value.([]any)
|
||||
if !ok || len(values) != 2 {
|
||||
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"between on trace-level aggregate %q requires exactly two values", alias)
|
||||
}
|
||||
if op == qbtypes.FilterOperatorBetween {
|
||||
cond = sb.Between(col, values[0], values[1])
|
||||
} else {
|
||||
cond = sb.NotBetween(col, values[0], values[1])
|
||||
}
|
||||
default:
|
||||
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"trace-level aggregate %q supports only comparison operators (=, !=, <, <=, >, >=, in, between)", alias)
|
||||
}
|
||||
return []string{cond}, nil, nil
|
||||
}
|
||||
@@ -32,9 +32,6 @@ type traceQueryStatementBuilder struct {
|
||||
resourceFilterResolver *resourcefilter.ResourceFingerprintResolver[qbtypes.TraceAggregation]
|
||||
aggExprRewriter qbtypes.AggExprRewriter
|
||||
skipResourceFingerprintEnabled bool
|
||||
// traceScope, set only on the per-call copy made by BuildTraceScoped, constrains
|
||||
// queries to spans whose trace_id is in the __trace_scope CTE.
|
||||
traceScope *qbtypes.Statement
|
||||
}
|
||||
|
||||
var _ qbtypes.StatementBuilder[qbtypes.TraceAggregation] = (*traceQueryStatementBuilder)(nil)
|
||||
@@ -98,33 +95,6 @@ func NewTraceQueryStatementBuilder(
|
||||
}
|
||||
}
|
||||
|
||||
// BuildTraceScoped is Build constrained to trace_ids selected by traceScope; the
|
||||
// receiver is copied so the shared builder stays stateless.
|
||||
func (b *traceQueryStatementBuilder) BuildTraceScoped(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start uint64,
|
||||
end uint64,
|
||||
requestType qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
traceScope *qbtypes.Statement,
|
||||
) (*qbtypes.Statement, error) {
|
||||
scoped := *b
|
||||
scoped.traceScope = traceScope
|
||||
return scoped.Build(ctx, orgID, start, end, requestType, query, variables)
|
||||
}
|
||||
|
||||
// attachTraceScope adds the trace-scope condition to sb and returns the CTE fragment
|
||||
// + args to prepend; both empty when no scope is set.
|
||||
func (b *traceQueryStatementBuilder) attachTraceScope(sb *sqlbuilder.SelectBuilder) (string, []any) {
|
||||
if b.traceScope == nil {
|
||||
return "", nil
|
||||
}
|
||||
sb.Where("trace_id GLOBAL IN (SELECT trace_id FROM __trace_scope)")
|
||||
return fmt.Sprintf("__trace_scope AS (%s)", b.traceScope.Query), b.traceScope.Args
|
||||
}
|
||||
|
||||
// Build builds a SQL query for traces based on the given parameters.
|
||||
func (b *traceQueryStatementBuilder) Build(
|
||||
ctx context.Context,
|
||||
@@ -549,11 +519,6 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
cteArgs = append(cteArgs, args)
|
||||
}
|
||||
|
||||
if scopeFrag, scopeArgs := b.attachTraceScope(sb); scopeFrag != "" {
|
||||
cteFragments = append(cteFragments, scopeFrag)
|
||||
cteArgs = append(cteArgs, scopeArgs)
|
||||
}
|
||||
|
||||
sb.SelectMore(fmt.Sprintf(
|
||||
"toStartOfInterval(timestamp, INTERVAL %d SECOND) AS ts",
|
||||
int64(query.StepInterval.Seconds()),
|
||||
@@ -714,13 +679,6 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
|
||||
cteArgs = append(cteArgs, args)
|
||||
}
|
||||
|
||||
// skipResourceCTE means this scalar is embedded as a CTE of a time-series query,
|
||||
// which has already emitted the __trace_scope fragment — add only the condition.
|
||||
if scopeFrag, scopeArgs := b.attachTraceScope(sb); scopeFrag != "" && !skipResourceCTE {
|
||||
cteFragments = append(cteFragments, scopeFrag)
|
||||
cteArgs = append(cteArgs, scopeArgs)
|
||||
}
|
||||
|
||||
allAggChArgs := []any{}
|
||||
|
||||
fieldNames := make([]string, 0, len(query.GroupBy))
|
||||
|
||||
82
tests/fixtures/querierai.py
vendored
82
tests/fixtures/querierai.py
vendored
@@ -1,16 +1,5 @@
|
||||
from datetime import datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.querier import (
|
||||
Aggregation,
|
||||
BuilderQuery,
|
||||
OrderBy,
|
||||
RequestType,
|
||||
TelemetryFieldKey,
|
||||
get_scalar_table_data,
|
||||
make_query_request,
|
||||
)
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
|
||||
|
||||
|
||||
@@ -43,10 +32,10 @@ def ai_trace(
|
||||
*,
|
||||
now: datetime,
|
||||
service: str,
|
||||
user: str,
|
||||
in_tokens: int | None,
|
||||
out_tokens: int,
|
||||
user: str = "user",
|
||||
cost: float = 0.1,
|
||||
cost: float,
|
||||
model: str = "gpt-4o-mini",
|
||||
environment: str = "production",
|
||||
) -> list[Traces]:
|
||||
@@ -85,28 +74,6 @@ def ai_trace(
|
||||
]
|
||||
|
||||
|
||||
def tool_only_trace(*, now: datetime, service: str) -> list[Traces]:
|
||||
"""Root + one tool span: passes the gen_ai gate but has NO LLM span."""
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
root_id = TraceIdGenerator.span_id()
|
||||
resources = {"service.name": service}
|
||||
return [
|
||||
root_span(now=now, trace_id=trace_id, span_id=root_id, resources=resources, duration_s=2),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=4),
|
||||
duration=timedelta(seconds=0.5),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=root_id,
|
||||
name="execute_tool",
|
||||
kind=TracesKind.SPAN_KIND_INTERNAL,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
attributes={"gen_ai.tool.name": "get_weather", "gen_ai.tool.type": "function"},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def ai_trace_mixed_spans(*, now: datetime, service: str, user: str) -> list[Traces]:
|
||||
"""Root + LLM + tool + agent spans; only the LLM span carries gen_ai.request.model."""
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
@@ -149,48 +116,3 @@ def ai_trace_mixed_spans(*, now: datetime, service: str, user: str) -> list[Trac
|
||||
),
|
||||
child("agent.step", TracesKind.SPAN_KIND_INTERNAL, {"gen_ai.agent.name": "chat-agent"}, 2),
|
||||
]
|
||||
|
||||
|
||||
def ai_aggregation_query(
|
||||
service: str,
|
||||
expression: str,
|
||||
*,
|
||||
filter_extra: str = "",
|
||||
group_by: list[TelemetryFieldKey] | None = None,
|
||||
alias: str | None = None,
|
||||
having: str | None = None,
|
||||
order: list[OrderBy] | None = None,
|
||||
limit: int | None = None,
|
||||
step_interval: int | None = None,
|
||||
) -> dict:
|
||||
filter_expression = f"service.name = '{service}'"
|
||||
if filter_extra:
|
||||
filter_expression += f" AND {filter_extra}"
|
||||
return BuilderQuery(
|
||||
signal="traces",
|
||||
query_type="builder_ai_query",
|
||||
name="A",
|
||||
filter_expression=filter_expression,
|
||||
aggregations=[Aggregation(expression=expression, alias=alias)],
|
||||
group_by=group_by,
|
||||
having_expression=having,
|
||||
order=order,
|
||||
limit=limit,
|
||||
step_interval=step_interval,
|
||||
).to_dict()
|
||||
|
||||
|
||||
def scalar_value(signoz: types.SigNoz, token: str, start_ms: int, end_ms: int, service: str, expression: str, filter_extra: str = "") -> float:
|
||||
"""The single cell of a one-aggregation, ungrouped scalar query."""
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[ai_aggregation_query(service, expression, filter_extra=filter_extra)],
|
||||
request_type=RequestType.SCALAR,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, f"{expression}: {response.text}"
|
||||
data = get_scalar_table_data(response.json())
|
||||
assert len(data) == 1, f"{expression}: expected one row, got {data}"
|
||||
return float(data[0][-1])
|
||||
|
||||
@@ -72,8 +72,8 @@ def test_ai_list_having_aggregate_filter(
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""One filter box splits into WHERE + HAVING; bare and `trace.` spellings behave
|
||||
identically; an output-only aggregate is rejected."""
|
||||
"""Span + aggregate condition in one filter box splits into WHERE + HAVING; bare
|
||||
and `trace.` spellings behave identically; an output-only aggregate is rejected."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-having"
|
||||
|
||||
@@ -322,8 +322,9 @@ def test_ai_list_nested_group_span_or_and_aggregate(
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""A nested (span OR span) group ANDed with an aggregate must not flatten: span
|
||||
predicates go to WHERE, the aggregate to HAVING."""
|
||||
"""service.name = X AND (has_error = true OR gen_ai.request.model = 'gpt-4o') AND
|
||||
total_tokens > 100: the nested OR group must not flatten, span predicates go to
|
||||
WHERE, the aggregate to HAVING."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-nested"
|
||||
|
||||
|
||||
@@ -1,528 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.querier import (
|
||||
Aggregation,
|
||||
BuilderQuery,
|
||||
OrderBy,
|
||||
RequestType,
|
||||
TelemetryFieldKey,
|
||||
get_all_series,
|
||||
get_scalar_columns,
|
||||
get_scalar_table_data,
|
||||
get_series_values,
|
||||
make_query_request,
|
||||
)
|
||||
from fixtures.querierai import ai_aggregation_query, ai_trace, query_window, scalar_value, tool_only_trace
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
|
||||
|
||||
|
||||
def test_ai_scalar_trace_level_aggregations(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""Scalars over per-trace values, and the bare-key span domain through the same request type."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-agg-scalar"
|
||||
insert_traces(ai_trace(now=now, service=service, in_tokens=10, out_tokens=100) + ai_trace(now=now, service=service, in_tokens=30, out_tokens=300))
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
def value(expression: str) -> float:
|
||||
return scalar_value(signoz, token, start_ms, end_ms, service, expression)
|
||||
|
||||
assert value("avg(trace.output_tokens)") == pytest.approx(200)
|
||||
assert value("count(trace.trace_id)") == 2
|
||||
assert value("max(trace.total_tokens)") == pytest.approx(330)
|
||||
assert value("p50(trace.output_tokens)") == pytest.approx(200) # AggreFuncMap -> quantile(0.50)
|
||||
# arithmetic inside one function and between functions
|
||||
assert value("avg(trace.output_tokens + trace.input_tokens)") == pytest.approx(220)
|
||||
assert value("sum(trace.output_tokens)/count(trace.trace_id)") == pytest.approx(200)
|
||||
assert value("count()") == 2 # the two LLM spans; roots are not gen_ai
|
||||
assert value("sum(gen_ai.usage.output_tokens)") == pytest.approx(400)
|
||||
|
||||
# multiple trace-level aggregations in one query -> one column per aggregation
|
||||
multi = BuilderQuery(
|
||||
signal="traces",
|
||||
query_type="builder_ai_query",
|
||||
name="A",
|
||||
filter_expression=f"service.name = '{service}'",
|
||||
aggregations=[Aggregation(expression="avg(trace.output_tokens)"), Aggregation(expression="count(trace.trace_id)")],
|
||||
)
|
||||
resp = make_query_request(signoz, token, start_ms, end_ms, [multi.to_dict()], request_type=RequestType.SCALAR)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
data = get_scalar_table_data(resp.json())
|
||||
assert len(data) == 1 and [float(v) for v in data[0]] == [pytest.approx(200), 2], data
|
||||
|
||||
|
||||
def test_ai_scalar_trace_level_filter_qualifies_traces(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""A trace-level condition qualifies whole traces before aggregation, on both domains."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-agg-qualify"
|
||||
insert_traces(ai_trace(now=now, service=service, in_tokens=10, out_tokens=100) + ai_trace(now=now, service=service, in_tokens=30, out_tokens=300))
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
for expression in (
|
||||
"sum(trace.output_tokens)", # native trace-domain path
|
||||
"sum(gen_ai.usage.output_tokens)", # delegated span-domain path (__trace_scope)
|
||||
):
|
||||
got = scalar_value(signoz, token, start_ms, end_ms, service, expression, filter_extra="trace.output_tokens > 100")
|
||||
assert got == pytest.approx(300), expression
|
||||
|
||||
# the qualification also constrains delegated (span-domain) time series
|
||||
resp = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[
|
||||
ai_aggregation_query(
|
||||
service,
|
||||
"sum(gen_ai.usage.output_tokens)",
|
||||
filter_extra="trace.output_tokens > 100",
|
||||
step_interval=60,
|
||||
)
|
||||
],
|
||||
request_type=RequestType.TIME_SERIES,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
assert [v["value"] for v in get_series_values(resp.json(), "A")] == [pytest.approx(300)]
|
||||
|
||||
|
||||
def test_ai_scalar_group_by_model(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""Trace-level aggregation grouped by a span attribute."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-agg-groupby"
|
||||
insert_traces(ai_trace(now=now, service=service, in_tokens=10, out_tokens=100, model="gpt-4o") + ai_trace(now=now, service=service, in_tokens=10, out_tokens=300, model="gpt-4o") + ai_trace(now=now, service=service, in_tokens=10, out_tokens=50, model="gpt-4o-mini"))
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
resp = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[ai_aggregation_query(service, "avg(trace.output_tokens)", group_by=[TelemetryFieldKey(name="gen_ai.request.model")])],
|
||||
request_type=RequestType.SCALAR,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
data = get_scalar_table_data(resp.json())
|
||||
by_model = {row[0]: float(row[-1]) for row in data}
|
||||
assert by_model == {"gpt-4o": pytest.approx(200), "gpt-4o-mini": pytest.approx(50)}, data
|
||||
|
||||
|
||||
def test_ai_scalar_group_by_intrinsic_span_column(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""Grouping by an intrinsic must not alias the group column to the span column it reads
|
||||
(`toString(name) AS name` is a cyclic alias ClickHouse rejects)."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-agg-groupby-intrinsic"
|
||||
insert_traces(ai_trace(now=now, service=service, in_tokens=10, out_tokens=100) + ai_trace(now=now, service=service, in_tokens=10, out_tokens=300) + tool_only_trace(now=now, service=service))
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
resp = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[
|
||||
ai_aggregation_query(
|
||||
service,
|
||||
"count(trace.trace_id)",
|
||||
group_by=[TelemetryFieldKey(name="name")],
|
||||
order=[OrderBy(key=TelemetryFieldKey(name="name"), direction="asc")],
|
||||
)
|
||||
],
|
||||
request_type=RequestType.SCALAR,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
columns = get_scalar_columns(resp.json())
|
||||
assert columns[0]["name"] == "name", columns
|
||||
data = get_scalar_table_data(resp.json())
|
||||
# the root spans are gated out, so each trace groups under its gen_ai span name
|
||||
assert [(row[0], int(row[-1])) for row in data] == [("chat gpt-4o-mini", 2), ("execute_tool", 1)], data
|
||||
|
||||
|
||||
def test_ai_timeseries_trace_level_aggregation(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-agg-ts"
|
||||
insert_traces(ai_trace(now=now, service=service, in_tokens=10, out_tokens=100) + ai_trace(now=now, service=service, in_tokens=30, out_tokens=300))
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
# all spans fall in one step bucket
|
||||
resp = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[ai_aggregation_query(service, "avg(trace.output_tokens)", step_interval=60)],
|
||||
request_type=RequestType.TIME_SERIES,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
assert [v["value"] for v in get_series_values(resp.json(), "A")] == [pytest.approx(200)]
|
||||
|
||||
|
||||
def test_ai_timeseries_top_n_groups(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""A grouped, limited time series ranks groups on whole-window per-trace values in
|
||||
the requested order."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-agg-topn"
|
||||
insert_traces(ai_trace(now=now, service=service, in_tokens=10, out_tokens=300, model="gpt-4o") + ai_trace(now=now, service=service, in_tokens=10, out_tokens=100, model="gpt-4o") + ai_trace(now=now, service=service, in_tokens=10, out_tokens=50, model="gpt-4o-mini"))
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
def top_series(order: list[OrderBy] | None) -> dict:
|
||||
resp = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[
|
||||
ai_aggregation_query(
|
||||
service,
|
||||
"sum(trace.output_tokens)",
|
||||
group_by=[TelemetryFieldKey(name="gen_ai.request.model")],
|
||||
alias="total_out",
|
||||
order=order,
|
||||
limit=1,
|
||||
step_interval=60,
|
||||
)
|
||||
],
|
||||
request_type=RequestType.TIME_SERIES,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
series = get_all_series(resp.json(), "A")
|
||||
assert len(series) == 1, f"limit=1 must keep exactly one group, got {len(series)} series"
|
||||
return series[0]
|
||||
|
||||
top = top_series(None) # default ranking: first aggregation desc
|
||||
assert top["labels"][0]["value"] == "gpt-4o", top["labels"]
|
||||
assert [v["value"] for v in top["values"]] == [pytest.approx(400)]
|
||||
|
||||
bottom = top_series([OrderBy(key=TelemetryFieldKey(name="total_out"), direction="asc")])
|
||||
assert bottom["labels"][0]["value"] == "gpt-4o-mini", bottom["labels"]
|
||||
assert [v["value"] for v in bottom["values"]] == [pytest.approx(50)]
|
||||
|
||||
|
||||
def test_ai_timeseries_limit_without_group_by(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""A time-series limit without group-by has nothing to rank and is ignored."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-agg-limit-nogroup"
|
||||
insert_traces(ai_trace(now=now, service=service, in_tokens=10, out_tokens=100) + ai_trace(now=now, service=service, in_tokens=10, out_tokens=300))
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
resp = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[ai_aggregation_query(service, "avg(trace.output_tokens)", limit=1, step_interval=60)],
|
||||
request_type=RequestType.TIME_SERIES,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
assert [v["value"] for v in get_series_values(resp.json(), "A")] == [pytest.approx(200)]
|
||||
|
||||
|
||||
def test_ai_scalar_group_order_limit(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""Scalar limit is a plain top-N over the grouped rows."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-agg-scalar-limit"
|
||||
insert_traces(
|
||||
ai_trace(now=now, service=service, in_tokens=10, out_tokens=300, model="gpt-4o")
|
||||
+ ai_trace(now=now, service=service, in_tokens=10, out_tokens=100, model="gpt-4o")
|
||||
+ ai_trace(now=now, service=service, in_tokens=10, out_tokens=50, model="gpt-4o-mini")
|
||||
+ ai_trace(now=now, service=service, in_tokens=10, out_tokens=10, model="gpt-4")
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
resp = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[
|
||||
ai_aggregation_query(
|
||||
service,
|
||||
"sum(trace.output_tokens)",
|
||||
group_by=[TelemetryFieldKey(name="gen_ai.request.model")],
|
||||
alias="total_out",
|
||||
order=[OrderBy(key=TelemetryFieldKey(name="total_out"), direction="desc")],
|
||||
limit=2,
|
||||
)
|
||||
],
|
||||
request_type=RequestType.SCALAR,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
data = get_scalar_table_data(resp.json())
|
||||
assert [(row[0], float(row[-1])) for row in data] == [("gpt-4o", pytest.approx(400)), ("gpt-4o-mini", pytest.approx(50))], data
|
||||
|
||||
|
||||
def test_ai_timeseries_span_time_bucketing(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""Per-trace values are clipped per (bucket, trace), so a trace spanning two buckets
|
||||
contributes each call's tokens to its own bucket, not the total to both."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-agg-buckets"
|
||||
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
root_id = TraceIdGenerator.span_id()
|
||||
resources = {"service.name": service}
|
||||
|
||||
def llm(offset_s: float, out_tokens: int) -> Traces:
|
||||
return Traces(
|
||||
timestamp=now - timedelta(seconds=offset_s),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=root_id,
|
||||
name="chat",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
attributes={"gen_ai.request.model": "gpt-4o-mini", "gen_ai.usage.output_tokens": out_tokens},
|
||||
)
|
||||
|
||||
root = Traces(
|
||||
timestamp=now - timedelta(seconds=130),
|
||||
duration=timedelta(seconds=130),
|
||||
trace_id=trace_id,
|
||||
span_id=root_id,
|
||||
parent_span_id="",
|
||||
name="POST /api/chat",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
attributes={"http.request.method": "POST"},
|
||||
)
|
||||
# two LLM calls two minutes apart
|
||||
insert_traces([root, llm(124, 100), llm(4, 300)])
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
resp = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[ai_aggregation_query(service, "avg(trace.output_tokens)", step_interval=60)],
|
||||
request_type=RequestType.TIME_SERIES,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
|
||||
series = get_all_series(resp.json(), "A")
|
||||
assert len(series) == 1, series
|
||||
assert sorted(v["value"] for v in series[0]["values"]) == [pytest.approx(100), pytest.approx(300)], series
|
||||
|
||||
|
||||
def test_ai_scalar_variables_in_trace_level_filter(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""Variables resolve inside trace-level conditions with span-filter semantics."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-agg-vars"
|
||||
insert_traces(ai_trace(now=now, service=service, in_tokens=10, out_tokens=100) + ai_trace(now=now, service=service, in_tokens=30, out_tokens=300))
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
query = ai_aggregation_query(service, "sum(trace.output_tokens)", filter_extra="trace.output_tokens > $threshold")
|
||||
|
||||
resp = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[query],
|
||||
request_type=RequestType.SCALAR,
|
||||
variables={"threshold": {"type": "text", "value": 100}},
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
data = get_scalar_table_data(resp.json())
|
||||
assert len(data) == 1 and float(data[0][-1]) == pytest.approx(300), data
|
||||
|
||||
# an unresolvable $var is a 400 today via aggregate validation
|
||||
resp = make_query_request(signoz, token, start_ms, end_ms, [query], request_type=RequestType.SCALAR)
|
||||
assert resp.status_code == HTTPStatus.BAD_REQUEST, resp.text
|
||||
# quotes in the message are JSON-escaped, so match the halves separately
|
||||
assert "$threshold" in resp.text and "cannot be used in a trace-level filter" in resp.text, resp.text
|
||||
|
||||
# a dynamic variable resolved to __all__ drops the condition (both traces count)
|
||||
resp = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[query],
|
||||
request_type=RequestType.SCALAR,
|
||||
variables={"threshold": {"type": "dynamic", "value": "__all__"}},
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
data = get_scalar_table_data(resp.json())
|
||||
assert len(data) == 1 and float(data[0][-1]) == pytest.approx(400), data
|
||||
|
||||
|
||||
def test_ai_scalar_tool_only_trace_null_semantics(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""A tool-only trace (in the gate, no LLM span) follows plain SQL NULL semantics."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-agg-toolonly"
|
||||
insert_traces(ai_trace(now=now, service=service, in_tokens=10, out_tokens=100) + tool_only_trace(now=now, service=service))
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
def value(expression: str, filter_extra: str = "") -> float:
|
||||
return scalar_value(signoz, token, start_ms, end_ms, service, expression, filter_extra)
|
||||
|
||||
assert value("count(trace.trace_id)") == 2, "tool-only trace is an AI trace and must be counted"
|
||||
assert value("avg(trace.output_tokens)") == pytest.approx(100), "NULL tokens are skipped by avg"
|
||||
assert value("avg(trace.tool_call_count)") == pytest.approx(0.5), "tool-only trace feeds tool aggregates (1 and 0 calls)"
|
||||
assert value("count()") == 2, "span-level count sees the LLM and the tool span"
|
||||
|
||||
# filtering on LLM activity is explicit, not implicit
|
||||
assert value("count(trace.trace_id)", filter_extra="trace.llm_call_count > 0") == 1
|
||||
|
||||
|
||||
def test_ai_scalar_having_on_aggregation(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""The outer having filters aggregation results per group (by alias)."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-agg-having"
|
||||
insert_traces(ai_trace(now=now, service=service, in_tokens=10, out_tokens=300, model="gpt-4o") + ai_trace(now=now, service=service, in_tokens=10, out_tokens=50, model="gpt-4o-mini"))
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
resp = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[
|
||||
ai_aggregation_query(
|
||||
service,
|
||||
"avg(trace.output_tokens)",
|
||||
group_by=[TelemetryFieldKey(name="gen_ai.request.model")],
|
||||
alias="avg_out",
|
||||
having="avg_out > 100",
|
||||
)
|
||||
],
|
||||
request_type=RequestType.SCALAR,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
data = get_scalar_table_data(resp.json())
|
||||
assert len(data) == 1 and data[0][0] == "gpt-4o", data
|
||||
|
||||
|
||||
def test_ai_aggregation_rejections(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-agg-reject"
|
||||
insert_traces(ai_trace(now=now, service=service, in_tokens=10, out_tokens=100))
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
def expect_bad_request(query: dict, message: str) -> None:
|
||||
resp = make_query_request(signoz, token, start_ms, end_ms, [query], request_type=RequestType.SCALAR)
|
||||
assert resp.status_code == HTTPStatus.BAD_REQUEST, resp.text
|
||||
assert message in resp.text, resp.text
|
||||
|
||||
# span-level and trace-level aggregations cannot be mixed in one query
|
||||
mixed = BuilderQuery(
|
||||
signal="traces",
|
||||
query_type="builder_ai_query",
|
||||
name="A",
|
||||
filter_expression=f"service.name = '{service}'",
|
||||
aggregations=[Aggregation(expression="avg(trace.output_tokens)"), Aggregation(expression="count()")],
|
||||
)
|
||||
expect_bad_request(mixed.to_dict(), "cannot be mixed")
|
||||
|
||||
expect_bad_request(
|
||||
ai_aggregation_query(service, "avg(trace.output_tokens)", group_by=[TelemetryFieldKey(name="trace.llm_call_count")]),
|
||||
"grouping by trace-level aggregate",
|
||||
)
|
||||
|
||||
# a bare per-trace column would emit one row per trace instead of one aggregated row
|
||||
expect_bad_request(ai_aggregation_query(service, "trace.output_tokens"), "must be inside an aggregation function")
|
||||
|
||||
# the rate interval divides the whole expression, so it may not carry a second aggregation
|
||||
expect_bad_request(ai_aggregation_query(service, "rate(trace.trace_id) + avg(trace.output_tokens)"), "combines a rate with another aggregation")
|
||||
|
||||
# order-by is stopped earlier, by request validation
|
||||
expect_bad_request(
|
||||
ai_aggregation_query(service, "avg(trace.output_tokens)", order=[OrderBy(key=TelemetryFieldKey(name="trace.total_tokens"), direction="desc")]),
|
||||
"invalid order by key",
|
||||
)
|
||||
@@ -1,32 +0,0 @@
|
||||
import pytest
|
||||
from testcontainers.core.container import Network
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.signoz import create_signoz
|
||||
|
||||
|
||||
@pytest.fixture(name="signoz", scope="package")
|
||||
def signoz_ai_observability(
|
||||
network: Network,
|
||||
migrator: types.Operation, # pylint: disable=unused-argument
|
||||
zeus: types.TestContainerDocker,
|
||||
gateway: types.TestContainerDocker,
|
||||
sqlstore: types.TestContainerSQL,
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.SigNoz:
|
||||
# without the flag the gate keys only resolve once a span carrying them is ingested
|
||||
return create_signoz(
|
||||
network=network,
|
||||
zeus=zeus,
|
||||
gateway=gateway,
|
||||
sqlstore=sqlstore,
|
||||
clickhouse=clickhouse,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
cache_key="signoz-ai-observability",
|
||||
env_overrides={
|
||||
"SIGNOZ_FLAGGER_CONFIG_BOOLEAN_ENABLE__AI__OBSERVABILITY": True,
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user