mirror of
https://github.com/hanzoai/insights-go.git
synced 2026-09-30 22:46:12 +00:00
merge: upstream/main (preserve Hanzo brand, 55 commits)
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/[email protected]/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": []
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
* @PostHog/team-client-libraries
|
||||
@@ -0,0 +1,41 @@
|
||||
name: 🐞 Bug Report
|
||||
description: Tell us about something that's not working the way we (probably) intend.
|
||||
labels: ["bug"]
|
||||
body:
|
||||
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: SDK Version
|
||||
placeholder: 3.0.0 ← should look like this
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: How can we see what you're seeing? Specific is terrific.
|
||||
placeholder: |-
|
||||
1. foo
|
||||
2. bar
|
||||
3. baz
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Result
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Result
|
||||
description: Logs? Screenshots? Yes, please.
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Ask in the forums
|
||||
url: https://posthog.com/questions
|
||||
about: A place to ask questions.
|
||||
@@ -0,0 +1,23 @@
|
||||
name: 💡 Feature Request
|
||||
description: Tell us about a problem our SDK could solve but doesn't.
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem Statement
|
||||
description: What problem could we solve that it doesn't?
|
||||
placeholder: |-
|
||||
I want to make whirled peas, but it doesn't blend.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Solution Brainstorm
|
||||
description: We know you have bright ideas to share ... share away, friend.
|
||||
placeholder: |-
|
||||
Add a blender to it.
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,10 @@
|
||||
name: Blank Issue
|
||||
description: Blank Issue. Reserved for maintainers.
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Please describe the issue.
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,21 @@
|
||||
## :bulb: Motivation and Context
|
||||
<!--- Why is this change required? What problem does it solve? -->
|
||||
<!--- If it fixes an open issue, please link to the issue here. -->
|
||||
|
||||
|
||||
## :green_heart: How did you test it?
|
||||
|
||||
|
||||
## :pencil: Checklist
|
||||
<!--- Put an `x` in the boxes that apply -->
|
||||
|
||||
- [ ] I reviewed the submitted code.
|
||||
- [ ] I added tests to verify the changes.
|
||||
- [ ] I updated the docs if needed.
|
||||
- [ ] No breaking change or entry added to the changelog.
|
||||
|
||||
### If releasing new changes
|
||||
|
||||
- [ ] Ran `pnpm changeset` to generate a changeset file
|
||||
|
||||
<!-- For more details check RELEASING.md -->
|
||||
@@ -9,7 +9,7 @@ on:
|
||||
|
||||
jobs:
|
||||
call-flags-project:
|
||||
uses: PostHog/.github/.github/workflows/flags-project-board.yml@main
|
||||
uses: PostHog/.github/.github/workflows/flags-project-board.yml@d8b55d05dc5150f05c24542a6397ff3ecfbfb56d
|
||||
with:
|
||||
pr_number: ${{ github.event.pull_request.number }}
|
||||
pr_node_id: ${{ github.event.pull_request.node_id }}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
name: "CodeQL Advanced"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
packages: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: go
|
||||
build-mode: autobuild
|
||||
- language: actions
|
||||
build-mode: none
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Go 1.21
|
||||
if: matrix.language == 'go'
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: 1.21
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
queries: security-and-quality
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
@@ -5,6 +5,9 @@ permissions:
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
merge_group:
|
||||
|
||||
jobs:
|
||||
@@ -13,12 +16,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Go 1.21
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: 1.21
|
||||
|
||||
- name: Check out source code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check formatting
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Lint PR
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- reopened
|
||||
- synchronize
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint-pr:
|
||||
name: Validate PR title against Conventional Commits
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: PostHog/.github/.github/workflows/semantic-pr-title.yml@926dd076f0c796f7531177ae5cfcf1cf7cf0aeb3
|
||||
+80
-108
@@ -1,59 +1,51 @@
|
||||
name: "Release"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [master]
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.changeset/*.md'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Concurrency control: only one release process can run at a time
|
||||
# This prevents race conditions if multiple PRs with 'release' label merge simultaneously
|
||||
# This prevents race conditions if multiple releasable changesets merge simultaneously
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
check-release-label:
|
||||
name: Check for release label
|
||||
check-changesets:
|
||||
name: Check for changesets
|
||||
runs-on: ubuntu-latest
|
||||
# Run when PR with 'release' label is merged to master, or when manually triggered
|
||||
if: |
|
||||
github.event.pull_request.merged == true
|
||||
&& contains(github.event.pull_request.labels.*.name, 'release')
|
||||
outputs:
|
||||
should-release: ${{ steps.check.outputs.should-release }}
|
||||
bump-type: ${{ steps.check.outputs.bump-type }}
|
||||
has-changesets: ${{ steps.check.outputs.has-changesets }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: master
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check release conditions
|
||||
- name: Check for changesets
|
||||
id: check
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Determine bump type from PR labels
|
||||
if ${{ contains(github.event.pull_request.labels.*.name, 'bump-major') }}; then
|
||||
echo "bump-type=major" >> "$GITHUB_OUTPUT"
|
||||
echo "should-release=true" >> "$GITHUB_OUTPUT"
|
||||
elif ${{ contains(github.event.pull_request.labels.*.name, 'bump-minor') }}; then
|
||||
echo "bump-type=minor" >> "$GITHUB_OUTPUT"
|
||||
echo "should-release=true" >> "$GITHUB_OUTPUT"
|
||||
elif ${{ contains(github.event.pull_request.labels.*.name, 'bump-patch') }}; then
|
||||
echo "bump-type=patch" >> "$GITHUB_OUTPUT"
|
||||
echo "should-release=true" >> "$GITHUB_OUTPUT"
|
||||
changeset_count=$(find .changeset -name '*.md' ! -name 'README.md' 2>/dev/null | wc -l)
|
||||
if [ "$changeset_count" -gt 0 ]; then
|
||||
echo "has-changesets=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Found $changeset_count changeset(s), ready to release"
|
||||
else
|
||||
echo "has-changesets=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No changesets to release"
|
||||
fi
|
||||
|
||||
notify-approval-needed:
|
||||
name: Notify Slack - Approval Needed
|
||||
needs: check-release-label
|
||||
if: needs.check-release-label.outputs.should-release == 'true'
|
||||
uses: posthog/.github/.github/workflows/notify-approval-needed.yml@main
|
||||
needs: check-changesets
|
||||
if: needs.check-changesets.outputs.has-changesets == 'true'
|
||||
uses: posthog/.github/.github/workflows/notify-approval-needed.yml@5fc4680761e8ac29a61b212756230eba0e276d8c
|
||||
with:
|
||||
slack_channel_id: ${{ vars.SLACK_APPROVALS_CLIENT_LIBRARIES_CHANNEL_ID }}
|
||||
slack_user_group_id: ${{ vars.GROUP_CLIENT_LIBRARIES_SLACK_GROUP_ID }}
|
||||
@@ -62,13 +54,11 @@ jobs:
|
||||
posthog_project_api_key: ${{ secrets.POSTHOG_PROJECT_API_KEY }}
|
||||
|
||||
release:
|
||||
name: Bump versions and release
|
||||
needs: [check-release-label, notify-approval-needed]
|
||||
name: Bump version and release
|
||||
needs: [check-changesets, notify-approval-needed]
|
||||
runs-on: ubuntu-latest
|
||||
# Use `always()` to ensure the job runs even if the check-release-label job fails
|
||||
# but still depend on it to be able to use `needs.notify-approval-needed.outputs.slack_ts`
|
||||
if: always() && needs.check-release-label.outputs.should-release == 'true'
|
||||
environment: "Release" # This will require an approval from a maintainer, they are notified in Slack above
|
||||
if: always() && needs.check-changesets.outputs.has-changesets == 'true'
|
||||
environment: "Release"
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
@@ -76,25 +66,24 @@ jobs:
|
||||
steps:
|
||||
- name: Notify Slack - Approved
|
||||
if: needs.notify-approval-needed.outputs.slack_ts != ''
|
||||
uses: posthog/.github/.github/actions/slack-thread-reply@main
|
||||
uses: posthog/.github/.github/actions/slack-thread-reply@5fc4680761e8ac29a61b212756230eba0e276d8c
|
||||
with:
|
||||
slack_bot_token: ${{ secrets.SLACK_CLIENT_LIBRARIES_BOT_TOKEN }}
|
||||
slack_channel_id: ${{ vars.SLACK_APPROVALS_CLIENT_LIBRARIES_CHANNEL_ID }}
|
||||
thread_ts: ${{ needs.notify-approval-needed.outputs.slack_ts }}
|
||||
message: "✅ Release approved! Version bump in progress..."
|
||||
emoji_reaction: "white_check_mark"
|
||||
|
||||
- name: Get GitHub App token
|
||||
id: releaser
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_POSTHOG_GO_RELEASER_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_POSTHOG_GO_RELEASER_PRIVATE_KEY }} # Secrets available only inside the 'Release' environment, requires approval from a maintainer
|
||||
client-id: ${{ secrets.GH_APP_POSTHOG_GO_RELEASER_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_POSTHOG_GO_RELEASER_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: master
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.releaser.outputs.token }}
|
||||
|
||||
@@ -103,79 +92,60 @@ jobs:
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Bump version
|
||||
id: bump-version
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Apply changesets and update version
|
||||
id: apply-changesets
|
||||
run: |
|
||||
current_version=$(grep -oP 'const Version = "\K[^"]+' version.go)
|
||||
IFS='.' read -ra version_parts <<< "$current_version"
|
||||
major=${version_parts[0]}
|
||||
minor=${version_parts[1]}
|
||||
patch=${version_parts[2]}
|
||||
pnpm changeset version
|
||||
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||
CURRENT_VERSION=$(grep -oP 'const Version = "\K[^"]+' version.go)
|
||||
sed -i "s/const Version = \"${CURRENT_VERSION}\"/const Version = \"${NEW_VERSION}\"/" version.go
|
||||
echo "new-version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "New version: $NEW_VERSION"
|
||||
|
||||
if [ "${{ needs.check-release-label.outputs.bump-type }}" == "major" ]; then
|
||||
new_version="$((major + 1)).0.0"
|
||||
elif [ "${{ needs.check-release-label.outputs.bump-type }}" == "minor" ]; then
|
||||
new_version="$major.$((minor + 1)).0"
|
||||
else
|
||||
new_version="$major.$minor.$((patch + 1))"
|
||||
fi
|
||||
|
||||
sed -i "s/const Version = \"$current_version\"/const Version = \"$new_version\"/" version.go
|
||||
echo "current_version=$current_version" >> $GITHUB_OUTPUT
|
||||
echo "new_version=$new_version" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update CHANGELOG.md
|
||||
- name: Check for version bump changes
|
||||
id: check-changes
|
||||
run: |
|
||||
current_version="${{ steps.bump-version.outputs.current_version }}"
|
||||
new_version="${{ steps.bump-version.outputs.new_version }}"
|
||||
release_date=$(date +%Y-%m-%d)
|
||||
echo -e "## $new_version - $release_date\n\n* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v${current_version}...v${new_version})\n\n$(cat CHANGELOG.md)" > CHANGELOG.md
|
||||
|
||||
- name: Commit version bump
|
||||
id: commit-version-bump
|
||||
run: |
|
||||
git add version.go CHANGELOG.md
|
||||
if git diff --staged --quiet; then
|
||||
if [ -z "$(git status --porcelain)" ]; then
|
||||
echo "No changes to commit"
|
||||
echo "committed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
git commit -m "chore: bump version to ${{ steps.bump-version.outputs.new_version }} [version bump]"
|
||||
git push origin master
|
||||
echo "committed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Commit version bump
|
||||
id: commit-version-bump
|
||||
if: steps.check-changes.outputs.committed == 'true'
|
||||
uses: planetscale/ghcommit-action@25309d8005ac7c3bcd61d3fe19b69e0fe47dbdde # v0.2.20
|
||||
with:
|
||||
commit_message: "chore: release v${{ steps.apply-changesets.outputs.new-version }} [version bump] [skip ci]"
|
||||
repo: ${{ github.repository }}
|
||||
branch: main
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.releaser.outputs.token }}
|
||||
|
||||
- name: Create and push tag
|
||||
if: steps.commit-version-bump.outputs.committed == 'true'
|
||||
run: |
|
||||
git tag -a "v${{ steps.bump-version.outputs.new_version }}" -m "v${{ steps.bump-version.outputs.new_version }}"
|
||||
git push origin "v${{ steps.bump-version.outputs.new_version }}"
|
||||
|
||||
- name: Create GitHub release
|
||||
if: steps.commit-version-bump.outputs.committed == 'true'
|
||||
if: steps.commit-version-bump.outputs.commit-hash != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.releaser.outputs.token }}
|
||||
NEW_VERSION: ${{ steps.apply-changesets.outputs.new-version }}
|
||||
run: |
|
||||
# Extract the latest changelog entry
|
||||
LAST_CHANGELOG_ENTRY=$(awk -v defText="see CHANGELOG.md" '/^## /{if (flag) exit; flag=1} flag && /^##$/{exit} flag; END{if (!flag) print defText}' CHANGELOG.md)
|
||||
gh api \
|
||||
--method POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
/repos/PostHog/posthog-go/releases \
|
||||
-f tag_name="v${{ steps.bump-version.outputs.new_version }}" \
|
||||
-f target_commitish='master' \
|
||||
-f name="v${{ steps.bump-version.outputs.new_version }}" \
|
||||
-f body="$LAST_CHANGELOG_ENTRY" \
|
||||
-F draft=false \
|
||||
-F prerelease=false \
|
||||
-F generate_release_notes=false
|
||||
CHANGELOG_ENTRY=$(awk -v defText="see CHANGELOG.md" '/^## /{if (flag) exit; flag=1} flag && /^##$/{exit} flag; END{if (!flag) print defText}' CHANGELOG.md)
|
||||
gh release create "v${NEW_VERSION}" --target main --title "${NEW_VERSION}" --notes "$CHANGELOG_ENTRY"
|
||||
|
||||
# Notify in case of a failure
|
||||
- name: Send failure event to PostHog
|
||||
if: ${{ failure() }}
|
||||
uses: PostHog/posthog-github-action@v0.1
|
||||
if: failure()
|
||||
uses: PostHog/posthog-github-action@58dea254b598fb5d469c0699c98af8288a7f7650 # v1.2.0
|
||||
with:
|
||||
posthog-token: "${{ secrets.POSTHOG_PROJECT_API_KEY }}"
|
||||
event: "posthog-go-github-release-workflow-failure"
|
||||
@@ -184,17 +154,17 @@ jobs:
|
||||
"commitSha": "${{ github.sha }}",
|
||||
"jobStatus": "${{ job.status }}",
|
||||
"ref": "${{ github.ref }}",
|
||||
"version": "v${{ steps.bump-version.outputs.new_version }}"
|
||||
"version": "${{ steps.apply-changesets.outputs.new-version }}"
|
||||
}
|
||||
|
||||
- name: Notify Slack - Failed
|
||||
if: ${{ failure() && needs.notify-approval-needed.outputs.slack_ts != '' }}
|
||||
uses: posthog/.github/.github/actions/slack-thread-reply@9b04bf3288aca2b4cd3883070858b034b4f7f334
|
||||
if: failure() && needs.notify-approval-needed.outputs.slack_ts != ''
|
||||
uses: posthog/.github/.github/actions/slack-thread-reply@5fc4680761e8ac29a61b212756230eba0e276d8c
|
||||
with:
|
||||
slack_bot_token: ${{ secrets.SLACK_CLIENT_LIBRARIES_BOT_TOKEN }}
|
||||
slack_channel_id: ${{ vars.SLACK_APPROVALS_CLIENT_LIBRARIES_CHANNEL_ID }}
|
||||
thread_ts: ${{ needs.notify-approval-needed.outputs.slack_ts }}
|
||||
message: "❌ Failed to release `posthog-go@v${{ steps.bump-version.outputs.new_version }}`! <https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|View logs>"
|
||||
message: "❌ Failed to release `posthog-go@${{ steps.apply-changesets.outputs.new-version }}`! <https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|View logs>"
|
||||
emoji_reaction: "x"
|
||||
|
||||
notify-rejected:
|
||||
@@ -207,8 +177,10 @@ jobs:
|
||||
id: check-rejection
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
RESPONSE=$(gh api /repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/approvals)
|
||||
RESPONSE=$(gh api "/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/approvals")
|
||||
REJECTED=$(echo "$RESPONSE" | jq '[.[] | select(.state == "rejected")] | length')
|
||||
if [ "$REJECTED" -gt 0 ]; then
|
||||
echo "was_rejected=true" >> "$GITHUB_OUTPUT"
|
||||
@@ -229,7 +201,7 @@ jobs:
|
||||
- name: Notify Slack - Rejected
|
||||
if: steps.check-rejection.outputs.was_rejected == 'true'
|
||||
continue-on-error: true
|
||||
uses: PostHog/.github/.github/actions/slack-thread-reply@main
|
||||
uses: posthog/.github/.github/actions/slack-thread-reply@5fc4680761e8ac29a61b212756230eba0e276d8c
|
||||
with:
|
||||
slack_bot_token: ${{ secrets.SLACK_CLIENT_LIBRARIES_BOT_TOKEN }}
|
||||
slack_channel_id: ${{ vars.SLACK_APPROVALS_CLIENT_LIBRARIES_CHANNEL_ID }}
|
||||
@@ -244,10 +216,10 @@ jobs:
|
||||
if: always() && needs.release.result == 'success' && needs.notify-approval-needed.outputs.slack_ts != ''
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Notify Slack - Released
|
||||
uses: posthog/.github/.github/actions/slack-thread-reply@9b04bf3288aca2b4cd3883070858b034b4f7f334
|
||||
uses: posthog/.github/.github/actions/slack-thread-reply@5fc4680761e8ac29a61b212756230eba0e276d8c
|
||||
with:
|
||||
slack_bot_token: ${{ secrets.SLACK_CLIENT_LIBRARIES_BOT_TOKEN }}
|
||||
slack_channel_id: ${{ vars.SLACK_APPROVALS_CLIENT_LIBRARIES_CHANNEL_ID }}
|
||||
|
||||
@@ -9,12 +9,12 @@ on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
|
||||
jobs:
|
||||
compliance:
|
||||
name: PostHog SDK compliance tests
|
||||
uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@main
|
||||
uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@39d05346c4638d24f94e591ab89c9c6fcdb52d6b
|
||||
with:
|
||||
adapter-dockerfile: "sdk_compliance_adapter/Dockerfile"
|
||||
adapter-context: "."
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
name: 'Handle stale issues and PRs'
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 9 * * 1-5'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Check if holiday period
|
||||
id: holiday
|
||||
run: |
|
||||
# Skip during Christmas/New Year holidays (Dec 20 - Jan 3)
|
||||
MONTH=$(TZ=UTC date +%m)
|
||||
DAY=$(TZ=UTC date +%-d)
|
||||
if [[ "$MONTH" == "12" && $DAY -ge 20 ]] || [[ "$MONTH" == "01" && $DAY -le 3 ]]; then
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
echo "Skipping stale check during holiday period"
|
||||
else
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
if: steps.holiday.outputs.skip != 'true'
|
||||
with:
|
||||
days-before-issue-stale: 730
|
||||
days-before-issue-close: 14
|
||||
stale-issue-message: "This issue hasn't seen activity in two years! If you want to keep it open, post a comment or remove the `stale` label – otherwise this will be closed in two weeks."
|
||||
close-issue-message: "This issue was closed due to lack of activity. Feel free to reopen if it's still relevant."
|
||||
stale-issue-label: stale
|
||||
remove-issue-stale-when-updated: true
|
||||
days-before-pr-stale: 7
|
||||
days-before-pr-close: 7
|
||||
stale-pr-message: "This PR hasn't seen activity in a week! Should it be merged, closed, or further worked on? If you want to keep it open, post a comment or remove the `stale` label – otherwise this will be closed in another week."
|
||||
close-pr-message: "This PR was closed due to lack of activity. Feel free to reopen if it's still relevant."
|
||||
stale-pr-label: stale
|
||||
remove-pr-stale-when-updated: true
|
||||
operations-per-run: 250
|
||||
@@ -2,6 +2,9 @@ name: Unit Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
merge_group:
|
||||
|
||||
jobs:
|
||||
@@ -10,15 +13,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Go 1.21
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: 1.21
|
||||
|
||||
- name: Check out source code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Build
|
||||
run: go build .
|
||||
|
||||
- name: Test
|
||||
run: go test -v -race -count=1 -timeout=5m -coverprofile=coverage.out ./...
|
||||
run: go test -v -race -count=1 -timeout=5m -coverprofile=coverage.out ./...
|
||||
|
||||
@@ -38,3 +38,4 @@ examples/.env
|
||||
|
||||
# Macos
|
||||
.DS_Store
|
||||
node_modules/
|
||||
|
||||
+149
-78
@@ -1,14 +1,85 @@
|
||||
## 1.10.0 - 2026-02-04
|
||||
## Unreleased
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.9.1...v1.10.0)
|
||||
## 1.13.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 541b82f: Include group context in the `$feature_flag_called` LRU dedupe key so group-scoped flags fire a separate event for each group a user is evaluated under, instead of being dedup-ed against the first group context the same `(distinct_id, flag, device_id)` was seen under.
|
||||
|
||||
## 1.13.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- dec8ade: Add opt-in panic capture for request context middleware.
|
||||
- dec8ade: Add server-side request context helpers for net/http capture and exception events, plus `EvaluateFlagsWithContext` for using request-scoped distinct IDs during flag evaluation. Request-context flag evaluation does not generate personless IDs.
|
||||
|
||||
## 1.12.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9289d53: Reject semver values with leading zeros in local flag evaluation. Per semver 2.0.0 §2, numeric identifiers must not include leading zeros — values like `1.07.3` are not valid semver and should not match targeting conditions. Both override values and flag values are now validated; invalid inputs surface an `InconclusiveMatchError` so the condition does not match.
|
||||
|
||||
## 1.12.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6d243a6: Return ErrSDKDisabled from no-op clients when the project API key is missing, return ErrNoPersonalAPIKey before making requests for Personal API key dependent methods when no Personal API key is configured, and return ErrNoDistinctID from EvaluateFlags when distinct_id is missing.
|
||||
|
||||
### New Features
|
||||
|
||||
* **`GetFeatureFlagResult`**: New method that returns both the flag value and payload in a single call, while properly tracking feature flag usage via `$feature_flag_called` events.
|
||||
- **`EvaluateFlags`**: New method on `Client` that returns a `FeatureFlagEvaluations` snapshot for a user using a single `/flags` request. The snapshot powers any number of `IsEnabled` / `GetFlag` / `GetFlagPayload` checks, fires deduped `$feature_flag_called` events with full v4 metadata (id, version, reason, request_id), and can be attached to a `Capture` event via the new `Capture.Flags` field to populate `$feature/<key>` and `$active_feature_flags` without another network call.
|
||||
- **`Capture.Flags`**: New optional field on `Capture` that accepts a `*FeatureFlagEvaluations` snapshot. Takes precedence over `SendFeatureFlags`, avoids a hidden `/flags` request per event, and lets caller-supplied `Properties` override the auto-generated `$feature/<key>` values on conflict.
|
||||
|
||||
### Internal
|
||||
|
||||
- Refactored the `$feature_flag_called` dedup logic into a shared helper so the existing single-flag path and the new snapshot path use identical semantics against the same per-distinct_id LRU cache.
|
||||
- `$feature_flag_called` events from the snapshot path combine response-level errors (`errors_while_computing_flags`, `quota_limited`) with per-flag errors (`flag_missing`) comma-joined in `$feature_flag_error`, matching the granularity of the legacy single-flag path.
|
||||
|
||||
## 1.12.4 - 2026-04-30
|
||||
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.12.3...v1.12.4)
|
||||
|
||||
## 1.12.3 - 2026-04-21
|
||||
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/1.12.2...1.12.3)
|
||||
|
||||
## 1.12.2 - 2026-04-20
|
||||
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/1.12.1...1.12.2)
|
||||
|
||||
## 1.12.1 - 2026-04-20
|
||||
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.12.0...v1.12.1)
|
||||
|
||||
## 1.12.0 - 2026-04-20
|
||||
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.11.3...v1.12.0)
|
||||
|
||||
## 1.11.3 - 2026-04-14
|
||||
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.11.2...v1.11.3)
|
||||
|
||||
- Added `locally_evaluated` property to `$feature_flag_called` events, indicating whether the flag was evaluated locally or via the remote `/flags` endpoint.
|
||||
|
||||
## 1.11.2 - 2026-03-26
|
||||
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.11.1...v1.11.2)
|
||||
|
||||
## 1.11.1 - 2026-03-11
|
||||
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.11.0...v1.11.1)
|
||||
|
||||
## 1.10.0 - 2026-02-04
|
||||
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.9.1...v1.10.0)
|
||||
|
||||
### New Features
|
||||
|
||||
- **`GetFeatureFlagResult`**: New method that returns both the flag value and payload in a single call, while properly tracking feature flag usage via `$feature_flag_called` events.
|
||||
|
||||
### Deprecations
|
||||
|
||||
* **`GetFeatureFlagPayload`**: Deprecated in favor of `GetFeatureFlagResult`. The new method provides better tracking and a more convenient API.
|
||||
- **`GetFeatureFlagPayload`**: Deprecated in favor of `GetFeatureFlagResult`. The new method provides better tracking and a more convenient API.
|
||||
|
||||
### Migration Guide
|
||||
|
||||
@@ -45,284 +116,284 @@ if result.Enabled {
|
||||
|
||||
## 1.9.1 - 2026-01-21
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.9.0...v1.9.1)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.9.0...v1.9.1)
|
||||
|
||||
## 1.9.0 - 2026-01-13
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.8.2...v1.9.0)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.8.2...v1.9.0)
|
||||
|
||||
## 1.8.2
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.8.1...v1.8.2)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.8.1...v1.8.2)
|
||||
|
||||
## 1.8.1
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.8.0...v1.8.1)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.8.0...v1.8.1)
|
||||
|
||||
## 1.8.0
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.7.0...v1.8.0)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.7.0...v1.8.0)
|
||||
|
||||
# 1.7.0
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.13...v1.7.0)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.13...v1.7.0)
|
||||
|
||||
## 1.6.13
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.12...v1.6.13)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.12...v1.6.13)
|
||||
|
||||
## 1.6.12
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.11...v1.6.12)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.11...v1.6.12)
|
||||
|
||||
## 1.6.11
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.10...v1.6.11)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.10...v1.6.11)
|
||||
|
||||
## 1.6.10
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.9...v1.6.10)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.9...v1.6.10)
|
||||
|
||||
## 1.6.9
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.8...v1.6.9)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.8...v1.6.9)
|
||||
|
||||
## 1.6.8
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.7...v1.6.8)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.7...v1.6.8)
|
||||
|
||||
## 1.6.7
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.6...v1.6.7)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.6...v1.6.7)
|
||||
|
||||
## 1.6.6
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.5...v1.6.6)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.5...v1.6.6)
|
||||
|
||||
## 1.6.5
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.4...v1.6.5)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.4...v1.6.5)
|
||||
|
||||
## 1.6.4
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.3...v1.6.4)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.3...v1.6.4)
|
||||
|
||||
## 1.6.3
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.2...v1.6.3)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.2...v1.6.3)
|
||||
|
||||
## 1.6.2
|
||||
|
||||
* Fix: Pass project API key in remote_config requests
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.1...v1.6.2)
|
||||
- Fix: Pass project API key in remote_config requests
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.1...v1.6.2)
|
||||
|
||||
## 1.6.1
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.0...v1.6.1)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.0...v1.6.1)
|
||||
|
||||
## 1.5.15
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.14...v1.5.15)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.14...v1.5.15)
|
||||
|
||||
## 1.5.14
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.13...v1.5.14)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.13...v1.5.14)
|
||||
|
||||
## 1.5.13
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.12...v1.5.13)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.12...v1.5.13)
|
||||
|
||||
## 1.5.12
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.11...v1.5.12)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.11...v1.5.12)
|
||||
|
||||
## 1.5.11
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.10...v1.5.11)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.10...v1.5.11)
|
||||
|
||||
## 1.5.10
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.9...v1.5.10)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.9...v1.5.10)
|
||||
|
||||
## 1.5.9
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.8...v1.5.9)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.8...v1.5.9)
|
||||
|
||||
## 1.5.8
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.7...v1.5.8)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.7...v1.5.8)
|
||||
|
||||
## 1.5.7
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.6...v1.5.7)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.6...v1.5.7)
|
||||
|
||||
## 1.5.6
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.5...v1.5.6)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.5...v1.5.6)
|
||||
|
||||
## 1.5.5
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.4...v1.5.5)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.4...v1.5.5)
|
||||
|
||||
## 1.5.4
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.3...v1.5.4)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.3...v1.5.4)
|
||||
|
||||
## 1.5.3
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.2...v1.5.3)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.2...v1.5.3)
|
||||
|
||||
## 1.5.2
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.1...v1.5.2)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.1...v1.5.2)
|
||||
|
||||
## 1.5.1
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.0...v1.5.1)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.5.0...v1.5.1)
|
||||
|
||||
## 1.4.10
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.4.9...v1.4.10)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.4.9...v1.4.10)
|
||||
|
||||
## 1.4.9
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.4.8...v1.4.9)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.4.8...v1.4.9)
|
||||
|
||||
## 1.4.8
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.4.7...v1.4.8)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.4.7...v1.4.8)
|
||||
|
||||
## 1.4.7
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.4.6...v1.4.7)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.4.6...v1.4.7)
|
||||
|
||||
## 1.4.6
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.4.5...v1.4.6)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.4.5...v1.4.6)
|
||||
|
||||
## 1.4.5
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.4.4...v1.4.5)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.4.4...v1.4.5)
|
||||
|
||||
## 1.4.4
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.4.3...v1.4.4)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.4.3...v1.4.4)
|
||||
|
||||
## 1.4.3
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.4.2...v1.4.3)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.4.2...v1.4.3)
|
||||
|
||||
## 1.4.2
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.4.1...v1.4.2)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.4.1...v1.4.2)
|
||||
|
||||
## 1.4.1
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.4.0...v1.4.1)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.4.0...v1.4.1)
|
||||
|
||||
## 1.4.0
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.3.3...v1.4.0)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.3.3...v1.4.0)
|
||||
|
||||
## 1.3.3
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.3.2...v1.3.3)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.3.2...v1.3.3)
|
||||
|
||||
## 1.3.2
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.3.1...v1.3.2)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.3.1...v1.3.2)
|
||||
|
||||
## 1.3.1
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.3.0...v1.3.1)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.3.0...v1.3.1)
|
||||
|
||||
## 1.2.24
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.2.23...v1.2.24)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.2.23...v1.2.24)
|
||||
|
||||
## 1.2.23
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.2.22...v1.2.23)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.2.22...v1.2.23)
|
||||
|
||||
## 1.2.22
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.2.21...v1.2.22)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.2.21...v1.2.22)
|
||||
|
||||
## 1.2.21
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.2.20...v1.2.21)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.2.20...v1.2.21)
|
||||
|
||||
## 1.2.20
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.2.19...v1.2.20)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.2.19...v1.2.20)
|
||||
|
||||
## 1.2.19
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.2.18...v1.2.19)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.2.18...v1.2.19)
|
||||
|
||||
## 1.2.18
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.2.17...v1.2.18)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.2.17...v1.2.18)
|
||||
|
||||
## 1.2.17
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.2.16...v1.2.17)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.2.16...v1.2.17)
|
||||
|
||||
## 1.2.16
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.2.15...v1.2.16)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.2.15...v1.2.16)
|
||||
|
||||
## 1.2.15
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.2.14...v1.2.15)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.2.14...v1.2.15)
|
||||
|
||||
## 1.2.14
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.2.13...v1.2.14)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.2.13...v1.2.14)
|
||||
|
||||
## 1.2.13
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.2.12...v1.2.13)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.2.12...v1.2.13)
|
||||
|
||||
## 1.2.12
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v...v1.2.12)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v...v1.2.12)
|
||||
|
||||
## 1.2.11
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v...v1.2.11)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v...v1.2.11)
|
||||
|
||||
## 1.2.10
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v...v1.2.10)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v...v1.2.10)
|
||||
|
||||
## 1.2.9
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v...v1.2.9)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v...v1.2.9)
|
||||
|
||||
## 1.2.8
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v...v1.2.8)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v...v1.2.8)
|
||||
|
||||
## 1.2.7
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v...v1.2.7)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v...v1.2.7)
|
||||
|
||||
## 1.2.6
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v...v1.2.6)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v...v1.2.6)
|
||||
|
||||
## 1.2.5
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v...v1.2.5)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v...v1.2.5)
|
||||
|
||||
## 1.2.4
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v...v1.2.4)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v...v1.2.4)
|
||||
|
||||
## 1.2.3
|
||||
|
||||
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v...v1.2.3)
|
||||
- [Full Changelog](https://github.com/PostHog/posthog-go/compare/v...v1.2.3)
|
||||
|
||||
# Changelog
|
||||
|
||||
@@ -333,7 +404,7 @@ if result.Enabled {
|
||||
|
||||
## 1.2.1 - 2024-08-07
|
||||
|
||||
1. The client will fall back to the `/decide` endpoint when evaluating feature flags if the user does not wish to provide a PersonalApiKey. This fixes an issue where users were unable to use this SDK without providing a PersonalApiKey. This fallback will make feature flag usage less performant, but will save users money by not making them pay for public API access.
|
||||
1. The client will fall back to the `/decide` endpoint when evaluating feature flags if the user does not wish to provide a PersonalApiKey. This fixes an issue where users were unable to use this SDK without providing a PersonalApiKey. This fallback will make feature flag usage less performant, but will save users money by not making them pay for public API access.
|
||||
|
||||
## 1.2.0 - 2022-08-15
|
||||
|
||||
@@ -342,5 +413,5 @@ Breaking changes:
|
||||
1. Minimum PostHog version requirement: 1.38
|
||||
2. Local Evaluation added to IsFeatureEnabled and GetFeatureFlag. These functions now accept person and group properties arguments. The arguments will be used to locally evaluate relevant feature flags.
|
||||
3. Feature flag functions take a payload called `FeatureFlagPayload` when a key is require and `FeatureFlagPayloadNoKey` when a key is not required. The payload will handle defaults for all unspecified arguments automatically.
|
||||
3. Feature Flag defaults have been removed. If the flag fails for any reason, nil will be returned.
|
||||
4. GetAllFlags argument added. This function returns all flags related to the id.
|
||||
4. Feature Flag defaults have been removed. If the flag fails for any reason, nil will be returned.
|
||||
5. GetAllFlags argument added. This function returns all flags related to the id.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for your interest in improving the PostHog Go SDK.
|
||||
|
||||
## Development setup
|
||||
|
||||
Make sure you have Go installed (macOS: `brew install go`, Linux / Windows: https://go.dev/doc/install).
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
make dependencies
|
||||
|
||||
# Run tests and build
|
||||
make build
|
||||
|
||||
# Just run tests
|
||||
make test
|
||||
```
|
||||
|
||||
## Testing local changes in another app
|
||||
|
||||
You can run your Go app against a local build of `posthog-go` by updating your app's `go.mod`, for example:
|
||||
|
||||
```go
|
||||
module example/posthog-go-app
|
||||
|
||||
go 1.22.5
|
||||
|
||||
require github.com/posthog/posthog-go v0.0.0-20240327112532-87b23fe11103
|
||||
|
||||
require github.com/google/uuid v1.3.0 // indirect
|
||||
|
||||
replace github.com/posthog/posthog-go => /path-to-your-local/posthog-go
|
||||
```
|
||||
|
||||
## Pull requests
|
||||
|
||||
Please keep changes focused and make sure the relevant tests pass before opening a PR.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Releasing
|
||||
|
||||
This repository uses [Changesets](https://github.com/changesets/changesets) for version management and an automated GitHub Actions workflow for releases.
|
||||
|
||||
## How to Release
|
||||
|
||||
### 1. Add a Changeset
|
||||
|
||||
When making a change that should be released, add a changeset:
|
||||
|
||||
```bash
|
||||
pnpm changeset
|
||||
```
|
||||
|
||||
This prompts you to select the version bump (`patch`, `minor`, or `major`) and write a short release summary. Commit the generated file in `.changeset/` with your pull request.
|
||||
|
||||
### 2. Merge the Pull Request
|
||||
|
||||
After review, merge the PR to `main`. No GitHub release label is required.
|
||||
|
||||
A push to `main` that includes `.changeset/*.md` changes automatically starts the release workflow. The workflow then:
|
||||
|
||||
1. Checks for pending changesets
|
||||
2. Notifies the client libraries team in Slack for approval
|
||||
3. Waits for approval from a maintainer via the GitHub `Release` environment
|
||||
4. The workflow applies Changesets, syncs `version.go`, creates a `vX.Y.Z` Git tag, and creates a GitHub Release.
|
||||
5. Notifies Slack when the release completes or fails
|
||||
|
||||
### Manual Trigger
|
||||
|
||||
You can also manually trigger the release workflow from the Actions tab with `workflow_dispatch`. Manual runs still require pending changesets.
|
||||
|
||||
## Version Bumping
|
||||
|
||||
Changesets determines the next version from the committed changeset files:
|
||||
|
||||
- **patch**: bug fixes, documentation updates, and internal changes
|
||||
- **minor**: backwards-compatible features
|
||||
- **major**: breaking changes
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No changesets found
|
||||
|
||||
If the release workflow reports that no changesets were found, make sure your PR includes at least one releasable `.changeset/*.md` file.
|
||||
@@ -6,17 +6,23 @@ import (
|
||||
|
||||
var _ Message = (*Alias)(nil)
|
||||
|
||||
// This type represents object sent in a alias call
|
||||
// Alias represents an alias call that links another distinct ID to an existing user.
|
||||
// Enqueue validates that DistinctId and Alias are both set, fills Type, Uuid,
|
||||
// Timestamp, and DisableGeoIP, then sends the message as a $create_alias event.
|
||||
type Alias struct {
|
||||
// This field is exported for serialization purposes and shouldn't be set by
|
||||
// the application, its value is always overwritten by the library.
|
||||
// Type is reserved for SDK serialization and is overwritten by Enqueue.
|
||||
Type string
|
||||
// Uuid is optional. If not provided, a random UUID will be generated.
|
||||
// Uuid is an optional event UUID. If empty, Enqueue generates a random UUID.
|
||||
Uuid string
|
||||
|
||||
Alias string
|
||||
DistinctId string
|
||||
Timestamp time.Time
|
||||
// Alias is the alternate distinct ID to attach to DistinctId.
|
||||
Alias string
|
||||
// DistinctId is the existing user distinct ID that Alias should resolve to.
|
||||
DistinctId string
|
||||
// Timestamp is the event timestamp. If zero, Enqueue uses the current time.
|
||||
Timestamp time.Time
|
||||
// DisableGeoIP controls whether this alias event disables GeoIP lookup.
|
||||
// Enqueue overwrites it from Config.GetDisableGeoIP.
|
||||
DisableGeoIP bool
|
||||
}
|
||||
|
||||
@@ -24,6 +30,7 @@ func (msg Alias) internal() {
|
||||
panic(unimplementedError)
|
||||
}
|
||||
|
||||
// Validate checks that the alias message has both DistinctId and Alias set.
|
||||
func (msg Alias) Validate() error {
|
||||
if len(msg.DistinctId) == 0 {
|
||||
return FieldError{
|
||||
@@ -44,26 +51,42 @@ func (msg Alias) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AliasInApiProperties is the wire-format properties object for an Alias message.
|
||||
type AliasInApiProperties struct {
|
||||
DistinctId string `json:"distinct_id"`
|
||||
Alias string `json:"alias"`
|
||||
Lib string `json:"$lib"`
|
||||
LibVersion string `json:"$lib_version"`
|
||||
DisableGeoIP bool `json:"$geoip_disable,omitempty"`
|
||||
sysContext
|
||||
// DistinctId is the canonical user distinct ID.
|
||||
DistinctId string `json:"distinct_id"`
|
||||
// Alias is the alternate distinct ID being linked to DistinctId.
|
||||
Alias string `json:"alias"`
|
||||
// Lib is the SDK name sent as $lib.
|
||||
Lib string `json:"$lib"`
|
||||
// LibVersion is the SDK version sent as $lib_version.
|
||||
LibVersion string `json:"$lib_version"`
|
||||
// DisableGeoIP is sent as $geoip_disable when GeoIP lookup is disabled.
|
||||
DisableGeoIP bool `json:"$geoip_disable,omitempty"`
|
||||
}
|
||||
|
||||
// AliasInApi is the wire-format payload produced from an Alias message.
|
||||
type AliasInApi struct {
|
||||
Type string `json:"type"`
|
||||
Uuid string `json:"uuid"`
|
||||
Library string `json:"library"`
|
||||
LibraryVersion string `json:"library_version"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
// Type is the message type sent to the batch API.
|
||||
Type string `json:"type"`
|
||||
// Uuid is the event UUID sent to the batch API.
|
||||
Uuid string `json:"uuid"`
|
||||
// Library is the SDK name sent to the batch API.
|
||||
Library string `json:"library"`
|
||||
// LibraryVersion is the SDK version sent to the batch API.
|
||||
LibraryVersion string `json:"library_version"`
|
||||
// Timestamp is the event timestamp sent to the batch API.
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
|
||||
// Properties contains alias-specific event properties.
|
||||
Properties AliasInApiProperties `json:"properties"`
|
||||
|
||||
// Event is always $create_alias for Alias messages.
|
||||
Event string `json:"event"`
|
||||
}
|
||||
|
||||
// APIfy converts an Alias message into the PostHog batch API representation.
|
||||
func (msg Alias) APIfy() APIMessage {
|
||||
libraryVersion := getVersion()
|
||||
|
||||
@@ -75,6 +98,7 @@ func (msg Alias) APIfy() APIMessage {
|
||||
LibraryVersion: libraryVersion,
|
||||
Timestamp: msg.Timestamp,
|
||||
Properties: AliasInApiProperties{
|
||||
sysContext: getSystemContext(),
|
||||
DistinctId: msg.DistinctId,
|
||||
Alias: msg.Alias,
|
||||
Lib: SDKName,
|
||||
|
||||
+9
-3
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Backoff calculates exponential retry delays with optional jitter and a maximum cap.
|
||||
type Backoff struct {
|
||||
base time.Duration
|
||||
factor uint8
|
||||
@@ -13,12 +14,13 @@ type Backoff struct {
|
||||
cap time.Duration
|
||||
}
|
||||
|
||||
// Creates a backo instance with the given parameters
|
||||
// NewBackoff creates a Backoff with the provided base duration, exponential
|
||||
// factor, jitter ratio, and maximum cap duration.
|
||||
func NewBackoff(base time.Duration, factor uint8, jitter float64, cap time.Duration) *Backoff {
|
||||
return &Backoff{base, factor, jitter, cap}
|
||||
}
|
||||
|
||||
// Creates a backoff instance with the following defaults:
|
||||
// DefaultBackoff creates a Backoff with the SDK's default retry policy:
|
||||
//
|
||||
// base: 100 milliseconds
|
||||
// factor: 2
|
||||
@@ -52,11 +54,14 @@ func (b *Backoff) Sleep(attempt int) {
|
||||
time.Sleep(duration)
|
||||
}
|
||||
|
||||
// Ticker delivers ticks using successive durations from a Backoff.
|
||||
type Ticker struct {
|
||||
done chan struct{}
|
||||
C <-chan time.Time
|
||||
// C receives the time for each backoff tick and is closed after Stop.
|
||||
C <-chan time.Time
|
||||
}
|
||||
|
||||
// NewTicker starts a ticker that waits Duration(0), Duration(1), and so on between ticks.
|
||||
func (b *Backoff) NewTicker() *Ticker {
|
||||
c := make(chan time.Time, 1)
|
||||
ticker := &Ticker{
|
||||
@@ -81,6 +86,7 @@ func (b *Backoff) NewTicker() *Ticker {
|
||||
return ticker
|
||||
}
|
||||
|
||||
// Stop stops the ticker goroutine and closes C. Stop should be called at most once.
|
||||
func (t *Ticker) Stop() {
|
||||
t.done <- struct{}{}
|
||||
}
|
||||
|
||||
+1
-1
@@ -203,7 +203,7 @@ func BenchmarkFeatureFlagLocalEvaluation(b *testing.B) {
|
||||
// Setup server that returns actual flag definitions
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation"):
|
||||
case strings.HasPrefix(r.URL.Path, "/flags/definitions"):
|
||||
w.Write([]byte(fixture("test-api-feature-flag.json")))
|
||||
case strings.HasPrefix(r.URL.Path, "/batch"):
|
||||
io.Copy(io.Discard, r.Body)
|
||||
|
||||
+69
-29
@@ -4,50 +4,59 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SendFeatureFlagsValue defines the interface for feature flag configuration
|
||||
// SendFeatureFlagsValue defines the legacy Capture.SendFeatureFlags configuration interface.
|
||||
type SendFeatureFlagsValue interface {
|
||||
// ShouldSend reports whether Enqueue should attach feature flag values to the Capture event.
|
||||
ShouldSend() bool
|
||||
// GetOptions returns optional feature flag evaluation parameters, or nil for boolean-only values.
|
||||
GetOptions() *SendFeatureFlagsOptions
|
||||
}
|
||||
|
||||
// SendFeatureFlagsOptions allows for more granular control over feature flag evaluation
|
||||
// SendFeatureFlagsOptions allows granular control over legacy feature flag evaluation for Capture.
|
||||
// Deprecated: Prefer Client.EvaluateFlags and Capture.Flags so captures use the exact
|
||||
// flag snapshot your application branched on without another /flags request.
|
||||
type SendFeatureFlagsOptions struct {
|
||||
// OnlyEvaluateLocally forces evaluation to only use local flags and never make API requests
|
||||
// OnlyEvaluateLocally forces evaluation to use locally loaded flag definitions only.
|
||||
OnlyEvaluateLocally bool
|
||||
// DeviceId provides a device_id for remote flag evaluation requests
|
||||
// DeviceId provides a device_id for remote flag evaluation requests.
|
||||
DeviceId *string
|
||||
// PersonProperties provides explicit person properties for local flag evaluation
|
||||
// PersonProperties provides explicit person properties for flag evaluation.
|
||||
PersonProperties Properties
|
||||
// GroupProperties provides explicit group properties for local flag evaluation
|
||||
// GroupProperties provides explicit group properties for flag evaluation.
|
||||
GroupProperties map[string]Properties
|
||||
}
|
||||
|
||||
// Implement SendFeatureFlagsValue interface for SendFeatureFlagsOptions
|
||||
// ShouldSend reports true when opts is non-nil.
|
||||
func (opts *SendFeatureFlagsOptions) ShouldSend() bool {
|
||||
return opts != nil
|
||||
}
|
||||
|
||||
// GetOptions returns opts so Capture can pass the options to feature flag evaluation.
|
||||
func (opts *SendFeatureFlagsOptions) GetOptions() *SendFeatureFlagsOptions {
|
||||
return opts
|
||||
}
|
||||
|
||||
// SendFeatureFlagsBool wraps a boolean value to implement SendFeatureFlagsValue
|
||||
// SendFeatureFlagsBool wraps a boolean value to implement SendFeatureFlagsValue.
|
||||
type SendFeatureFlagsBool bool
|
||||
|
||||
// Implement SendFeatureFlagsValue interface for SendFeatureFlagsBool
|
||||
// ShouldSend reports the wrapped boolean value.
|
||||
func (b SendFeatureFlagsBool) ShouldSend() bool {
|
||||
return bool(b)
|
||||
}
|
||||
|
||||
// GetOptions returns nil because SendFeatureFlagsBool has no additional options.
|
||||
func (b SendFeatureFlagsBool) GetOptions() *SendFeatureFlagsOptions {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Constructor functions for easier usage
|
||||
// SendFeatureFlags converts enabled into a legacy Capture.SendFeatureFlags value.
|
||||
// Deprecated: Prefer Client.EvaluateFlags and Capture.Flags.
|
||||
func SendFeatureFlags(enabled bool) SendFeatureFlagsValue {
|
||||
return SendFeatureFlagsBool(enabled)
|
||||
}
|
||||
|
||||
// SendFeatureFlagsWithOptions wraps legacy feature flag capture options.
|
||||
// Deprecated: Prefer Client.EvaluateFlags and Capture.Flags.
|
||||
func SendFeatureFlagsWithOptions(opts *SendFeatureFlagsOptions) SendFeatureFlagsValue {
|
||||
return opts
|
||||
}
|
||||
@@ -69,10 +78,12 @@ func (c *Capture) getFeatureFlagsOptions() *SendFeatureFlagsOptions {
|
||||
|
||||
var _ Message = (*Capture)(nil)
|
||||
|
||||
// This type represents object sent in a capture call
|
||||
// Capture represents a custom event to send to PostHog.
|
||||
// Enqueue validates Event and DistinctId (or obtains DistinctId from request context),
|
||||
// fills Type, Uuid, and Timestamp, merges Config.DefaultEventProperties, and queues
|
||||
// the event for a future batch upload.
|
||||
type Capture struct {
|
||||
// This field is exported for serialization purposes and shouldn't be set by
|
||||
// the application, its value is always overwritten by the library.
|
||||
// Type is reserved for SDK serialization and is overwritten by Enqueue.
|
||||
Type string
|
||||
// You don't usually need to specify this field - Insights will generate it automatically.
|
||||
// Use it only when necessary - for example, to prevent duplicate events.
|
||||
@@ -83,12 +94,20 @@ type Capture struct {
|
||||
Properties Properties
|
||||
Groups Groups
|
||||
SendFeatureFlags SendFeatureFlagsValue
|
||||
// Flags, when set, attaches $feature/<key> and $active_feature_flags
|
||||
// properties from a snapshot returned by Client.EvaluateFlags. It is
|
||||
// preferred over SendFeatureFlags: the snapshot guarantees the event
|
||||
// carries the exact values the application branched on and avoids a
|
||||
// hidden /flags request on every capture. Flags takes precedence when
|
||||
// both are set.
|
||||
Flags *FeatureFlagEvaluations
|
||||
}
|
||||
|
||||
func (msg Capture) internal() {
|
||||
panic(unimplementedError)
|
||||
}
|
||||
|
||||
// Validate checks that the capture message has a non-empty Event and DistinctId.
|
||||
func (msg Capture) Validate() error {
|
||||
if len(msg.Event) == 0 {
|
||||
return FieldError{
|
||||
@@ -109,29 +128,50 @@ func (msg Capture) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type CaptureInApi struct {
|
||||
Type string `json:"type"`
|
||||
Uuid string `json:"uuid"`
|
||||
Library string `json:"library"`
|
||||
LibraryVersion string `json:"library_version"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
func validateCaptureEvent(msg Capture) error {
|
||||
if len(msg.Event) == 0 {
|
||||
return FieldError{
|
||||
Type: "posthog.Capture",
|
||||
Name: "Event",
|
||||
Value: msg.Event,
|
||||
}
|
||||
}
|
||||
|
||||
DistinctId string `json:"distinct_id"`
|
||||
Event string `json:"event"`
|
||||
Properties Properties `json:"properties"`
|
||||
return nil
|
||||
}
|
||||
|
||||
// CaptureInApi is the wire-format payload produced from a Capture message.
|
||||
type CaptureInApi struct {
|
||||
// Type is the message type sent to the batch API.
|
||||
Type string `json:"type"`
|
||||
// Uuid is the event UUID sent to the batch API.
|
||||
Uuid string `json:"uuid"`
|
||||
// Library is the SDK name sent to the batch API.
|
||||
Library string `json:"library"`
|
||||
// LibraryVersion is the SDK version sent to the batch API.
|
||||
LibraryVersion string `json:"library_version"`
|
||||
// Timestamp is the event timestamp sent to the batch API.
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
|
||||
// DistinctId identifies the user or entity for the event.
|
||||
DistinctId string `json:"distinct_id"`
|
||||
// Event is the captured event name.
|
||||
Event string `json:"event"`
|
||||
// Properties contains event, SDK, system, group, and feature flag properties.
|
||||
Properties Properties `json:"properties"`
|
||||
// SendFeatureFlags carries the legacy send_feature_flags value for compatibility.
|
||||
SendFeatureFlags SendFeatureFlagsValue `json:"send_feature_flags"`
|
||||
}
|
||||
|
||||
// APIfy converts a Capture message into the PostHog batch API representation.
|
||||
func (msg Capture) APIfy() APIMessage {
|
||||
libraryVersion := getVersion()
|
||||
|
||||
myProperties := Properties{}.Set("$lib", SDKName).Set("$lib_version", libraryVersion)
|
||||
|
||||
if msg.Properties != nil {
|
||||
for k, v := range msg.Properties {
|
||||
myProperties[k] = v
|
||||
}
|
||||
}
|
||||
myProperties := Properties{}.
|
||||
Merge(msg.Properties).
|
||||
Set("$lib", SDKName).
|
||||
Set("$lib_version", libraryVersion).
|
||||
Merge(getSystemContext().ToProperties())
|
||||
|
||||
if msg.Groups != nil {
|
||||
myProperties.Set("$groups", msg.Groups)
|
||||
|
||||
+1
-1
@@ -133,7 +133,7 @@ func TestConcurrentFeatureFlagEvaluation(t *testing.T) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
// Return proper format for both local evaluation and flags endpoints
|
||||
if r.URL.Path == "/api/feature_flag/local_evaluation" {
|
||||
if r.URL.Path == "/flags/definitions" {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"flags": []map[string]interface{}{
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ package insights
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -16,15 +17,14 @@ const (
|
||||
CompressionGzip CompressionMode = 1
|
||||
)
|
||||
|
||||
// Config carries the different configuration options that may
|
||||
// be set when instantiating a client.
|
||||
// Config carries configuration options used when constructing a Client with NewWithConfig.
|
||||
//
|
||||
// Each field's zero-value is either meaningful or interpreted as using the
|
||||
// default value defined by the library.
|
||||
// Each exported field's zero value is either meaningful or replaced by the
|
||||
// default documented on that field.
|
||||
type Config struct {
|
||||
|
||||
// The endpoint to which the client connect and send their messages, set to
|
||||
// `DefaultEndpoint` by default.
|
||||
// Endpoint is the PostHog API host used for event ingestion and feature flag requests.
|
||||
// If empty, it defaults to DefaultEndpoint.
|
||||
Endpoint string
|
||||
|
||||
// Specifying a Personal API key will make feature flag evaluation more performant,
|
||||
@@ -34,97 +34,90 @@ type Config struct {
|
||||
// Information on how to get a personal API key: https://insights.hanzo.ai/docs/api/overview
|
||||
PersonalApiKey string
|
||||
|
||||
// DisableGeoIP will disable GeoIP lookup for events and when fetching feature flags
|
||||
// DisableGeoIP controls whether event and feature flag requests include
|
||||
// $geoip_disable/geoip_disable. Nil defaults to true because this SDK usually
|
||||
// runs server-side; set Ptr(false) to allow GeoIP lookup.
|
||||
DisableGeoIP *bool
|
||||
|
||||
// The flushing interval of the client. Messages will be sent when they've
|
||||
// been queued up to the maximum batch size or when the flushing interval
|
||||
// timer triggers. If zero, defaults to 5 seconds.
|
||||
// Interval is the flush interval for queued messages. Messages are sent when
|
||||
// BatchSize is reached or when this timer fires. If zero, it defaults to
|
||||
// DefaultInterval.
|
||||
Interval time.Duration
|
||||
|
||||
// Interval at which to fetch new feature flag definitions, 5min by default
|
||||
// DefaultFeatureFlagsPollingInterval is the interval for reloading local feature
|
||||
// flag definitions when PersonalApiKey is configured. If zero, it defaults to
|
||||
// DefaultFeatureFlagsPollingInterval.
|
||||
DefaultFeatureFlagsPollingInterval time.Duration
|
||||
|
||||
// Timeout for fetching feature flags, 3 seconds by default
|
||||
// FeatureFlagRequestTimeout is the timeout for feature flag and remote config
|
||||
// HTTP requests. If zero, it defaults to DefaultFeatureFlagRequestTimeout.
|
||||
// Use time.Duration values such as 3 * time.Second.
|
||||
FeatureFlagRequestTimeout time.Duration
|
||||
|
||||
// Calculate when feature flag definitions should be polled next. Setting this property
|
||||
// will override DefaultFeatureFlagsPollingInterval.
|
||||
// NextFeatureFlagsPollingTick optionally calculates the next local feature flag
|
||||
// polling delay. When set, it overrides DefaultFeatureFlagsPollingInterval.
|
||||
NextFeatureFlagsPollingTick func() time.Duration
|
||||
|
||||
// Flag to enable historical migration
|
||||
// See more in our migration docs: https://insights.hanzo.ai/docs/migrate
|
||||
HistoricalMigration bool
|
||||
|
||||
// The HTTP transport used by the client, this allows an application to
|
||||
// redefine how requests are being sent at the HTTP level (for example,
|
||||
// to change the connection pooling policy).
|
||||
// If none is specified the client uses `http.DefaultTransport`.
|
||||
// Transport is the HTTP transport used by the client. Set it to customize
|
||||
// low-level request behavior such as connection pooling or proxies. If nil,
|
||||
// the client uses a clone of http.DefaultTransport with SDK defaults.
|
||||
Transport http.RoundTripper
|
||||
|
||||
// Logger used by the client to output info or error messages when that
|
||||
// are generated by background operations.
|
||||
// If none is specified the client uses a standard logger that outputs to
|
||||
// `os.Stderr`. Override this to suppress log messages.
|
||||
// Logger receives informational, warning, and error messages from background
|
||||
// operations. If nil, the client logs to os.Stderr with the standard logger.
|
||||
Logger Logger
|
||||
|
||||
// Properties that will be included in every event sent by the client.
|
||||
// This is useful for adding common metadata like service name or app version across all events.
|
||||
// If a property conflict occurs, the value from DefaultEventProperties will overwrite any existing value.
|
||||
// DefaultEventProperties are merged into every Capture event before sending.
|
||||
// They are useful for common metadata like service name or app version. On key
|
||||
// conflicts, values from DefaultEventProperties overwrite event properties.
|
||||
DefaultEventProperties Properties
|
||||
|
||||
// The callback object that will be used by the client to notify the
|
||||
// application when messages sends to the backend API succeeded or failed.
|
||||
// Callback receives success or failure notifications for messages sent to the
|
||||
// PostHog batch API.
|
||||
Callback Callback
|
||||
|
||||
// The maximum number of messages that will be sent in one API call.
|
||||
// Messages will be sent when they've been queued up to the maximum batch
|
||||
// size or when the flushing interval timer triggers. If zero, defaults to 250.
|
||||
// Note that the API will still enforce a 500KB limit on each HTTP request
|
||||
// which is independent from the number of embedded messages.
|
||||
// BatchSize is the maximum number of messages sent in one batch API call.
|
||||
// Messages are sent when BatchSize is reached or when Interval fires. If zero,
|
||||
// it defaults to DefaultBatchSize. The API still enforces a 500KB request limit.
|
||||
BatchSize int
|
||||
|
||||
// When set to true the client will send more frequent and detailed messages
|
||||
// to its logger.
|
||||
// Verbose enables more frequent and detailed debug logging through Logger.
|
||||
Verbose bool
|
||||
|
||||
// The retry policy used by the client to resend requests that have failed.
|
||||
// The function is called with how many times the operation has been retried
|
||||
// and is expected to return how long the client should wait before trying
|
||||
// again.
|
||||
// If not set the client will fallback to use a default retry policy.
|
||||
// RetryAfter returns the delay before retrying a failed batch upload. The int
|
||||
// argument is the retry attempt number. If nil, DefaultBackoff().Duration is used.
|
||||
RetryAfter func(int) time.Duration
|
||||
|
||||
// MaxRetries is the maximum number of times a request is retried on failure.
|
||||
// Must be in range [0,9]. If nil, defaults to 9 (10 total attempts).
|
||||
// MaxRetries is the maximum number of retries after the first send attempt.
|
||||
// It must be in [0,9]. If nil, it defaults to 9 retries (10 total attempts).
|
||||
MaxRetries *int
|
||||
|
||||
// ShutdownTimeout is the maximum time to wait for in-flight messages
|
||||
// to be sent during Close(). If zero or negative, waits indefinitely
|
||||
// (preserving backward-compatible behavior). Set to a positive duration
|
||||
// to enable timeout-based shutdown.
|
||||
// ShutdownTimeout is the maximum time Close waits for in-flight messages to be
|
||||
// sent. If zero or negative, Close waits indefinitely for backward compatibility.
|
||||
ShutdownTimeout time.Duration
|
||||
|
||||
// BatchUploadTimeout is the timeout for uploading batched events to the
|
||||
// /batch/ endpoint. If zero, defaults to 10 seconds.
|
||||
// BatchUploadTimeout is the timeout for uploading one batch to the /batch/
|
||||
// endpoint. If zero, it defaults to DefaultBatchUploadTimeout.
|
||||
BatchUploadTimeout time.Duration
|
||||
|
||||
// BatchSubmitTimeout is the maximum time to wait when submitting a batch
|
||||
// to the worker pool if the queue is full. This provides backpressure
|
||||
// smoothing during transient backend latency spikes, reducing data loss.
|
||||
// If zero, defaults to 100ms. Set to a negative value for non-blocking
|
||||
// behavior (immediate drop when queue is full).
|
||||
// BatchSubmitTimeout is the maximum time to wait when submitting a batch to the
|
||||
// worker pool while its queue is full. If zero, it defaults to
|
||||
// DefaultBatchSubmitTimeout. Set a negative duration for non-blocking behavior
|
||||
// that drops immediately when the queue is full.
|
||||
BatchSubmitTimeout time.Duration
|
||||
|
||||
// MaxEnqueuedRequests is the maximum number of batches that can be queued
|
||||
// for sending. When the queue is full, new batches are dropped and the
|
||||
// failure callback is invoked. If zero, defaults to 1000.
|
||||
// MaxEnqueuedRequests is the maximum number of batches waiting for upload.
|
||||
// When the queue is full, new batches are dropped and the failure callback is
|
||||
// invoked. If zero, it defaults to DefaultMaxEnqueuedRequests.
|
||||
MaxEnqueuedRequests int
|
||||
|
||||
// Compression specifies the compression mode for batch payloads.
|
||||
// When set to CompressionGzip, payloads are GZIP compressed before
|
||||
// sending, and appropriate headers/query params are added.
|
||||
// Defaults to CompressionNone (no compression).
|
||||
// Compression selects the compression mode for batch payloads. CompressionGzip
|
||||
// compresses payloads and adds the appropriate headers/query params. If zero,
|
||||
// it defaults to CompressionNone.
|
||||
Compression CompressionMode
|
||||
|
||||
// A function called by the client to get the current time, `time.Now` is
|
||||
@@ -149,18 +142,16 @@ const (
|
||||
// messages if none was explicitly set.
|
||||
DefaultEndpoint = "https://insights.hanzo.ai"
|
||||
|
||||
// DefaultInterval constant sets the default flush interval used by client instances if
|
||||
// none was explicitly set.
|
||||
// DefaultInterval is the default flush interval used when Config.Interval is zero.
|
||||
DefaultInterval = 5 * time.Second
|
||||
|
||||
// DefaultFeatureFlagsPollingInterval the default interval at which to fetch new feature flags
|
||||
// DefaultFeatureFlagsPollingInterval is the default local feature flag reload interval.
|
||||
DefaultFeatureFlagsPollingInterval = 5 * time.Minute
|
||||
|
||||
// DefaultFeatureFlagRequestTimeout the default timeout for fetching feature flags
|
||||
// DefaultFeatureFlagRequestTimeout is the default timeout for feature flag requests.
|
||||
DefaultFeatureFlagRequestTimeout = 3 * time.Second
|
||||
|
||||
// DefaultBatchSize sets the default batch size used by client instances if none
|
||||
// was explicitly set.
|
||||
// DefaultBatchSize is the default batch size used when Config.BatchSize is zero.
|
||||
DefaultBatchSize = 250
|
||||
|
||||
// DefaultBatchUploadTimeout is the default timeout for uploading batched
|
||||
@@ -177,9 +168,16 @@ const (
|
||||
DefaultMaxEnqueuedRequests = 1000
|
||||
)
|
||||
|
||||
func (c *Config) normalize() {
|
||||
c.Endpoint = strings.TrimSpace(c.Endpoint)
|
||||
c.PersonalApiKey = strings.TrimSpace(c.PersonalApiKey)
|
||||
}
|
||||
|
||||
// Validate verifies that fields that don't have zero-values are set to valid values,
|
||||
// returns an error describing the problem if a field was invalid.
|
||||
func (c *Config) Validate() error {
|
||||
c.normalize()
|
||||
|
||||
if c.Interval < 0 {
|
||||
return ConfigError{
|
||||
Reason: "negative time intervals are not supported",
|
||||
@@ -226,6 +224,8 @@ func (c *Config) Validate() error {
|
||||
// Given a config object as argument the function will set all zero-values to
|
||||
// their defaults and return the modified object.
|
||||
func makeConfig(c Config) Config {
|
||||
c.normalize()
|
||||
|
||||
if len(c.Endpoint) == 0 {
|
||||
c.Endpoint = DefaultEndpoint
|
||||
}
|
||||
@@ -292,7 +292,7 @@ func makeConfig(c Config) Config {
|
||||
return c
|
||||
}
|
||||
|
||||
// Ptr is a helper to easily make reference from value.
|
||||
// Ptr returns a pointer to v.
|
||||
func Ptr[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
|
||||
@@ -15,6 +15,22 @@ func TestConfigZeroValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidateTrimsWhitespaceSensitiveFields(t *testing.T) {
|
||||
c := Config{
|
||||
Endpoint: " \nhttps://app.posthog.com\t ",
|
||||
PersonalApiKey: " \ttest-personal-key\n ",
|
||||
}
|
||||
|
||||
require.NoError(t, c.Validate())
|
||||
require.Equal(t, "https://app.posthog.com", c.Endpoint)
|
||||
require.Equal(t, "test-personal-key", c.PersonalApiKey)
|
||||
}
|
||||
|
||||
func TestMakeConfigDefaultsWhitespaceEndpoint(t *testing.T) {
|
||||
got := makeConfig(Config{Endpoint: " \n\t "})
|
||||
require.Equal(t, DefaultEndpoint, got.Endpoint)
|
||||
}
|
||||
|
||||
func TestConfigInvalidInterval(t *testing.T) {
|
||||
c := Config{
|
||||
Interval: -1 * time.Second,
|
||||
|
||||
@@ -5,8 +5,7 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Returned by the `NewWithConfig` function when the one of the configuration
|
||||
// fields was set to an impossible value (like a negative duration).
|
||||
// ConfigError is returned by NewWithConfig when a Config field has an invalid value.
|
||||
type ConfigError struct {
|
||||
|
||||
// A human-readable message explaining why the configuration field's value
|
||||
@@ -20,13 +19,12 @@ type ConfigError struct {
|
||||
Value interface{}
|
||||
}
|
||||
|
||||
// Error returns a human-readable configuration error message.
|
||||
func (e ConfigError) Error() string {
|
||||
return fmt.Sprintf("insights.NewWithConfig: %s (insights.Config.%s: %#v)", e.Reason, e.Field, e.Value)
|
||||
}
|
||||
|
||||
// Instances of this type are used to represent errors returned when a field was
|
||||
// no initialize properly in a structure passed as argument to one of the
|
||||
// functions of this package.
|
||||
// FieldError describes an invalid required field in a message passed to the SDK.
|
||||
type FieldError struct {
|
||||
|
||||
// The human-readable representation of the type of structure that wasn't
|
||||
@@ -40,6 +38,7 @@ type FieldError struct {
|
||||
Value interface{}
|
||||
}
|
||||
|
||||
// Error returns a human-readable field validation error message.
|
||||
func (e FieldError) Error() string {
|
||||
return fmt.Sprintf("%s.%s: invalid field value: %#v", e.Type, e.Name, e.Value)
|
||||
}
|
||||
@@ -58,7 +57,14 @@ var (
|
||||
// limit.
|
||||
ErrMessageTooBig = errors.New("the message exceeds the maximum allowed size")
|
||||
|
||||
// ErrNoPersonalAPIKey is returned when one tries to use feature flags
|
||||
// without specifying a PersonalAPIKey.
|
||||
// ErrNoPersonalAPIKey is returned when PersonalApiKey is required for the
|
||||
// requested operation but was not configured.
|
||||
ErrNoPersonalAPIKey = errors.New("no PersonalAPIKey provided")
|
||||
|
||||
// ErrNoDistinctID is returned when distinct_id is required for the requested
|
||||
// operation but was not provided.
|
||||
ErrNoDistinctID = errors.New("no distinct_id provided")
|
||||
|
||||
// ErrSDKDisabled is returned when the SDK is disabled because the project API key is missing.
|
||||
ErrSDKDisabled = errors.New("posthog SDK is disabled because project API key is missing")
|
||||
)
|
||||
|
||||
+122
-34
@@ -1,78 +1,157 @@
|
||||
package insights
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
var _ Message = (*Exception)(nil)
|
||||
|
||||
// Exception represents an error tracking exception event.
|
||||
// Enqueue validates DistinctId and ExceptionList (or obtains DistinctId from request context),
|
||||
// fills Type, Uuid, Timestamp, and DisableGeoIP, then sends the message as a $exception event.
|
||||
type Exception struct {
|
||||
// This field is exported for serialization purposes and shouldn't be set by
|
||||
// the application, its value is always overwritten by the library.
|
||||
// Type is reserved for SDK serialization and is overwritten by Enqueue.
|
||||
Type string
|
||||
// Uuid is optional. If not provided, a random UUID will be generated.
|
||||
// Uuid is an optional event UUID. If empty, Enqueue generates a random UUID.
|
||||
Uuid string
|
||||
|
||||
DistinctId string
|
||||
Timestamp time.Time
|
||||
// DistinctId identifies the user or entity associated with the exception.
|
||||
// When using EnqueueWithContext, this can be inherited from RequestContext.
|
||||
DistinctId string
|
||||
// Timestamp is the event timestamp. If zero, Enqueue uses the current time.
|
||||
Timestamp time.Time
|
||||
// Properties are custom event properties flattened into the exception event.
|
||||
Properties Properties
|
||||
// DisableGeoIP controls whether this exception event disables GeoIP lookup.
|
||||
// Enqueue overwrites it from Config.GetDisableGeoIP.
|
||||
DisableGeoIP bool
|
||||
|
||||
// Typed properties that end up in the API "properties" object:
|
||||
ExceptionList []ExceptionItem
|
||||
// ExceptionList is the list of exception items sent as $exception_list. It must be non-empty.
|
||||
ExceptionList []ExceptionItem
|
||||
// ExceptionFingerprint optionally overrides PostHog's default exception grouping fingerprint.
|
||||
ExceptionFingerprint *string
|
||||
}
|
||||
|
||||
// ExceptionItem describes one exception in an Exception event.
|
||||
type ExceptionItem struct {
|
||||
// Type will be rendered as title in the UI
|
||||
// Type is rendered as the exception title in the PostHog UI.
|
||||
Type string `json:"type"`
|
||||
// Value will be rendered as description in the UI
|
||||
Value string `json:"value"`
|
||||
// Value is rendered as the exception description in the PostHog UI.
|
||||
Value string `json:"value"`
|
||||
// Mechanism describes how the exception was produced or handled.
|
||||
Mechanism *ExceptionMechanism `json:"mechanism,omitempty"`
|
||||
// Stacktrace can conveniently be generated through the use of StackTraceExtractor
|
||||
// Stacktrace contains stack frames for the exception. Use StackTraceExtractor to generate it.
|
||||
Stacktrace *ExceptionStacktrace `json:"stacktrace,omitempty"`
|
||||
}
|
||||
|
||||
// ExceptionMechanism describes whether an exception was handled or synthesized.
|
||||
type ExceptionMechanism struct {
|
||||
Handled *bool `json:"handled,omitempty"`
|
||||
// Handled reports whether the exception was handled by application code.
|
||||
Handled *bool `json:"handled,omitempty"`
|
||||
// Synthetic reports whether the exception was synthesized by instrumentation.
|
||||
Synthetic *bool `json:"synthetic,omitempty"`
|
||||
}
|
||||
|
||||
// ExceptionStacktrace is a PostHog-compatible stack trace.
|
||||
type ExceptionStacktrace struct {
|
||||
Type string `json:"type"`
|
||||
// Type is the stack trace representation type. The default extractor uses "raw".
|
||||
Type string `json:"type"`
|
||||
// Frames is the ordered list of stack frames.
|
||||
Frames []StackFrame `json:"frames"`
|
||||
}
|
||||
|
||||
// StackFrame represents a single "Frame" within a stack trace.
|
||||
type StackFrame struct {
|
||||
Filename string `json:"filename"`
|
||||
LineNo int `json:"lineno"`
|
||||
Function string `json:"function"`
|
||||
InApp bool `json:"in_app"`
|
||||
Synthetic bool `json:"synthetic"`
|
||||
Platform string `json:"platform"`
|
||||
// Filename is the source file path for the frame.
|
||||
Filename string `json:"filename"`
|
||||
// LineNo is the one-based source line number.
|
||||
LineNo int `json:"lineno"`
|
||||
// Function is the fully qualified function name.
|
||||
Function string `json:"function"`
|
||||
// InApp reports whether the frame is considered application code.
|
||||
InApp bool `json:"in_app"`
|
||||
// Synthetic reports whether the frame was synthesized by instrumentation.
|
||||
Synthetic bool `json:"synthetic"`
|
||||
// Platform identifies the runtime platform; Go frames use "go".
|
||||
Platform string `json:"platform"`
|
||||
}
|
||||
|
||||
// ExceptionInApi is the wire-format payload produced from an Exception message.
|
||||
type ExceptionInApi struct {
|
||||
Type string `json:"type"`
|
||||
Uuid string `json:"uuid"`
|
||||
Library string `json:"library"`
|
||||
LibraryVersion string `json:"library_version"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Event string `json:"event"`
|
||||
Properties ExceptionInApiProperties `json:"properties"`
|
||||
// Type is the message type sent to the batch API.
|
||||
Type string `json:"type"`
|
||||
// Uuid is the event UUID sent to the batch API.
|
||||
Uuid string `json:"uuid"`
|
||||
// Library is the SDK name sent to the batch API.
|
||||
Library string `json:"library"`
|
||||
// LibraryVersion is the SDK version sent to the batch API.
|
||||
LibraryVersion string `json:"library_version"`
|
||||
// Timestamp is the event timestamp sent to the batch API.
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
// Event is always $exception for Exception messages.
|
||||
Event string `json:"event"`
|
||||
// Properties contains built-in and custom exception properties.
|
||||
Properties ExceptionInApiProperties `json:"properties"`
|
||||
}
|
||||
|
||||
// ExceptionInApiProperties is the wire-format properties object for an Exception message.
|
||||
type ExceptionInApiProperties struct {
|
||||
Lib string `json:"$lib"`
|
||||
LibVersion string `json:"$lib_version"`
|
||||
DistinctId string `json:"distinct_id"`
|
||||
DisableGeoIP bool `json:"$geoip_disable,omitempty"`
|
||||
ExceptionList []ExceptionItem `json:"$exception_list"`
|
||||
ExceptionFingerprint *string `json:"$exception_fingerprint,omitempty"`
|
||||
sysContext
|
||||
// Lib is the SDK name sent as $lib.
|
||||
Lib string `json:"$lib"`
|
||||
// LibVersion is the SDK version sent as $lib_version.
|
||||
LibVersion string `json:"$lib_version"`
|
||||
// DistinctId is the user distinct ID associated with the exception.
|
||||
DistinctId string `json:"distinct_id"`
|
||||
// DisableGeoIP is sent as $geoip_disable when GeoIP lookup is disabled.
|
||||
DisableGeoIP bool `json:"$geoip_disable,omitempty"`
|
||||
// ExceptionList is sent as $exception_list.
|
||||
ExceptionList []ExceptionItem `json:"$exception_list"`
|
||||
// ExceptionFingerprint is sent as $exception_fingerprint when provided.
|
||||
ExceptionFingerprint *string `json:"$exception_fingerprint,omitempty"`
|
||||
|
||||
// Custom is flattened into the wire "properties" on marshal.
|
||||
// Typed fields win on collision.
|
||||
Custom Properties `json:"-"`
|
||||
}
|
||||
|
||||
// MarshalJSON lets users add extra keys (via Custom) to the event's
|
||||
// JSON output next to the built-in ones like $lib and distinct_id.
|
||||
// If a custom key clashes with a built-in, we keep ours and drop theirs.
|
||||
func (p ExceptionInApiProperties) MarshalJSON() ([]byte, error) {
|
||||
type alias ExceptionInApiProperties
|
||||
typedBytes, err := json.Marshal(alias(p))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(p.Custom) == 0 {
|
||||
return typedBytes, nil
|
||||
}
|
||||
|
||||
var merged map[string]json.RawMessage
|
||||
if err := json.Unmarshal(typedBytes, &merged); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for k, v := range p.Custom {
|
||||
if _, exists := merged[k]; exists {
|
||||
continue
|
||||
}
|
||||
vb, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
merged[k] = vb
|
||||
}
|
||||
|
||||
return json.Marshal(merged)
|
||||
}
|
||||
|
||||
func (msg Exception) internal() { panic(unimplementedError) }
|
||||
|
||||
// Validate checks that the exception has a DistinctId and at least one valid ExceptionItem.
|
||||
func (msg Exception) Validate() error {
|
||||
if len(msg.DistinctId) == 0 {
|
||||
return FieldError{
|
||||
@@ -97,6 +176,7 @@ func (msg Exception) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks that the exception item has Type and Value set.
|
||||
func (msg ExceptionItem) Validate() error {
|
||||
if msg.Type == "" {
|
||||
return FieldError{
|
||||
@@ -116,6 +196,7 @@ func (msg ExceptionItem) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// APIfy converts an Exception message into the PostHog batch API representation.
|
||||
func (msg Exception) APIfy() APIMessage {
|
||||
libVersion := getVersion()
|
||||
|
||||
@@ -127,18 +208,25 @@ func (msg Exception) APIfy() APIMessage {
|
||||
LibraryVersion: libVersion,
|
||||
Timestamp: msg.Timestamp,
|
||||
Properties: ExceptionInApiProperties{
|
||||
sysContext: getSystemContext(),
|
||||
Lib: SDKName,
|
||||
LibVersion: libVersion,
|
||||
DistinctId: msg.DistinctId,
|
||||
DisableGeoIP: msg.DisableGeoIP,
|
||||
ExceptionList: msg.ExceptionList,
|
||||
ExceptionFingerprint: msg.ExceptionFingerprint,
|
||||
Custom: msg.Properties,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewDefaultException is a convenience function to build an Exception object (usable for `client.Enqueue`)
|
||||
// with sane defaults. If you want more control, please manually build the Exception object.
|
||||
// NewDefaultException builds an Exception with a default stack trace.
|
||||
//
|
||||
// The timestamp parameter becomes Exception.Timestamp, distinctID becomes
|
||||
// Exception.DistinctId, title becomes the first ExceptionItem.Type, and
|
||||
// description becomes the first ExceptionItem.Value. The returned Exception is
|
||||
// ready to pass to Client.Enqueue. Build Exception manually if you need custom
|
||||
// properties, fingerprints, mechanisms, or stack frames.
|
||||
func NewDefaultException(
|
||||
timestamp time.Time,
|
||||
distinctID, title, description string,
|
||||
|
||||
+36
-3
@@ -14,7 +14,13 @@ type SlogCaptureHandler struct {
|
||||
cfg captureConfig
|
||||
}
|
||||
|
||||
// NewSlogCaptureHandler creates a new log handler wrapper.
|
||||
// NewSlogCaptureHandler wraps next and mirrors qualifying slog records to PostHog.
|
||||
//
|
||||
// The next parameter receives all records as your normal handler. The client
|
||||
// parameter receives captured Exception messages. The opts parameters customize
|
||||
// capture level, distinct ID resolution, stack traces, descriptions, fingerprints,
|
||||
// and extra properties.
|
||||
//
|
||||
// You typically wrap your existing handler:
|
||||
//
|
||||
// client, err := insights.NewWithConfig(...)
|
||||
@@ -33,6 +39,7 @@ func NewSlogCaptureHandler(next slog.Handler, client EnqueueClient, opts ...Slog
|
||||
return &SlogCaptureHandler{next: next, client: client, cfg: cfg}
|
||||
}
|
||||
|
||||
// Enabled reports whether either the wrapped handler or PostHog capture wants records at level.
|
||||
func (h *SlogCaptureHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
// Ensure we get called for capture-level records even if the base doesn't log them.
|
||||
if level >= h.cfg.minCaptureLevel {
|
||||
@@ -42,6 +49,8 @@ func (h *SlogCaptureHandler) Enabled(ctx context.Context, level slog.Level) bool
|
||||
return h.next.Enabled(ctx, level)
|
||||
}
|
||||
|
||||
// Handle forwards the record to the wrapped handler and captures qualifying records as exceptions.
|
||||
// Enqueue errors are ignored so logging remains non-intrusive.
|
||||
func (h *SlogCaptureHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
// Always forward to the wrapped handler first.
|
||||
var err error
|
||||
@@ -69,12 +78,14 @@ func (h *SlogCaptureHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
},
|
||||
},
|
||||
ExceptionFingerprint: h.cfg.fingerprint(ctx, r),
|
||||
Properties: h.cfg.properties(ctx, r),
|
||||
}
|
||||
_ = h.client.Enqueue(ex) // ignore enqueue error to keep logging safe
|
||||
_ = enqueueWithContext(ctx, h.client, ex) // ignore enqueue error to keep logging safe
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// WithAttrs returns a handler with attrs applied to the wrapped handler.
|
||||
func (h *SlogCaptureHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return &SlogCaptureHandler{
|
||||
next: h.next.WithAttrs(attrs),
|
||||
@@ -83,6 +94,7 @@ func (h *SlogCaptureHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// WithGroup returns a handler with name applied to the wrapped handler's group.
|
||||
func (h *SlogCaptureHandler) WithGroup(name string) slog.Handler {
|
||||
return &SlogCaptureHandler{
|
||||
next: h.next.WithGroup(name),
|
||||
@@ -91,12 +103,30 @@ func (h *SlogCaptureHandler) WithGroup(name string) slog.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// SlogAttrsAsProperties is a convenience implementation of the fn passed to
|
||||
// WithPropertiesFn. It copies every slog.Record attribute into a Properties
|
||||
// map, so log fields flow onto the captured exception event verbatim.
|
||||
//
|
||||
// Usage: posthog.WithPropertiesFn(posthog.SlogAttrsAsProperties)
|
||||
//
|
||||
// Note: this ships all attrs to PostHog — make sure none contain sensitive
|
||||
// data, or write a filtering fn instead.
|
||||
func SlogAttrsAsProperties(_ context.Context, r slog.Record) Properties {
|
||||
props := NewProperties()
|
||||
r.Attrs(func(a slog.Attr) bool {
|
||||
props.Set(a.Key, a.Value.Any())
|
||||
return true
|
||||
})
|
||||
return props
|
||||
}
|
||||
|
||||
// DescriptionExtractor defines the interface for extracting a human-readable
|
||||
// description from a slog.Record for use in exception capture.
|
||||
//
|
||||
// Implementations should always return a non-empty string; returning an empty
|
||||
// string will prevent the error from being captured.
|
||||
type DescriptionExtractor interface {
|
||||
// ExtractDescription returns the ExceptionItem.Value to use for a slog record.
|
||||
ExtractDescription(r slog.Record) string
|
||||
}
|
||||
|
||||
@@ -107,10 +137,13 @@ type DescriptionExtractor interface {
|
||||
// The first matching attribute with an error value is returned as the
|
||||
// description. If no matching error is found, the Fallback is returned.
|
||||
type ErrorExtractor struct {
|
||||
// ErrorKeys are attribute keys to inspect for error values, case-insensitively.
|
||||
ErrorKeys []string
|
||||
Fallback string
|
||||
// Fallback is returned when no matching error attribute is found.
|
||||
Fallback string
|
||||
}
|
||||
|
||||
// ExtractDescription returns the first matching error string from the slog record or Fallback.
|
||||
func (e ErrorExtractor) ExtractDescription(r slog.Record) string {
|
||||
var found error
|
||||
|
||||
|
||||
@@ -36,6 +36,11 @@ type captureConfig struct {
|
||||
// record for fields like "err" or "error" and uses the extracted
|
||||
// error message as the description.
|
||||
descriptionExtractor DescriptionExtractor
|
||||
|
||||
// properties optionally returns custom properties to attach to the
|
||||
// captured exception event. Returning nil attaches no extra properties.
|
||||
// Built-in exception keys (e.g. "$lib", "distinct_id") win on collision.
|
||||
properties func(ctx context.Context, r slog.Record) Properties
|
||||
}
|
||||
|
||||
func defaultCaptureConfig() captureConfig {
|
||||
@@ -55,23 +60,35 @@ func defaultCaptureConfig() captureConfig {
|
||||
ErrorKeys: []string{"err", "error"},
|
||||
Fallback: "<no linked error>",
|
||||
},
|
||||
properties: func(ctx context.Context, r slog.Record) Properties {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SlogOption customizes NewSlogCaptureHandler.
|
||||
type SlogOption func(*captureConfig)
|
||||
|
||||
// WithMinCaptureLevel sets the minimum slog level captured as a PostHog exception.
|
||||
// Records below this level are still passed to the wrapped handler.
|
||||
func WithMinCaptureLevel(l slog.Level) SlogOption {
|
||||
return func(c *captureConfig) { c.minCaptureLevel = l }
|
||||
}
|
||||
|
||||
// WithDistinctIDFn sets the function used to resolve the exception DistinctId.
|
||||
// The fn parameter must be non-nil. If fn returns an empty string, the slog record is not captured.
|
||||
func WithDistinctIDFn(fn func(ctx context.Context, r slog.Record) string) SlogOption {
|
||||
return func(c *captureConfig) { c.distinctID = fn }
|
||||
}
|
||||
|
||||
// WithFingerprintFn sets the function used to compute an optional exception fingerprint.
|
||||
// The fn parameter must be non-nil.
|
||||
func WithFingerprintFn(fn func(ctx context.Context, r slog.Record) *string) SlogOption {
|
||||
return func(c *captureConfig) { c.fingerprint = fn }
|
||||
}
|
||||
|
||||
// WithSkip sets how many stack frames the stack trace extractor should skip.
|
||||
// Values less than or equal to zero are ignored.
|
||||
func WithSkip(n int) SlogOption {
|
||||
return func(c *captureConfig) {
|
||||
if n > 0 {
|
||||
@@ -80,10 +97,24 @@ func WithSkip(n int) SlogOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithStackTraceExtractor sets the StackTraceExtractor used for captured exceptions.
|
||||
// The extractor parameter must be non-nil.
|
||||
func WithStackTraceExtractor(extractor StackTraceExtractor) SlogOption {
|
||||
return func(c *captureConfig) { c.stackTraceExtractor = extractor }
|
||||
}
|
||||
|
||||
// WithDescriptionExtractor sets the DescriptionExtractor used for captured exceptions.
|
||||
// The extractor parameter must be non-nil.
|
||||
func WithDescriptionExtractor(extractor DescriptionExtractor) SlogOption {
|
||||
return func(c *captureConfig) { c.descriptionExtractor = extractor }
|
||||
}
|
||||
|
||||
// WithPropertiesFn sets a function that returns custom properties for captured exceptions.
|
||||
// Built-in exception properties take precedence if keys collide.
|
||||
func WithPropertiesFn(fn func(ctx context.Context, r slog.Record) Properties) SlogOption {
|
||||
return func(c *captureConfig) {
|
||||
if fn != nil {
|
||||
c.properties = fn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,3 +178,58 @@ func TestErrorExtractor_ExtractDescription(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlogCaptureHandler_WithPropertiesFn(t *testing.T) {
|
||||
next := &fakeNextSlogHandler{isEnabled: true}
|
||||
client := &fakeEnqueueClient{}
|
||||
ctx := context.Background()
|
||||
|
||||
handler := NewSlogCaptureHandler(next, client,
|
||||
WithMinCaptureLevel(slog.LevelWarn),
|
||||
WithDistinctIDFn(func(_ context.Context, _ slog.Record) string {
|
||||
return "test-user"
|
||||
}),
|
||||
WithPropertiesFn(func(_ context.Context, r slog.Record) Properties {
|
||||
props := NewProperties()
|
||||
r.Attrs(func(a slog.Attr) bool {
|
||||
props.Set(a.Key, a.Value.Any())
|
||||
return true
|
||||
})
|
||||
return props
|
||||
}),
|
||||
)
|
||||
|
||||
record := createLogRecord(slog.LevelError, "test error",
|
||||
slog.String("environment", "production"),
|
||||
slog.Int("retry_count", 3),
|
||||
)
|
||||
|
||||
if err := handler.Handle(ctx, record); err != nil {
|
||||
t.Fatalf("Handle returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(client.enqueuedMsgs) != 1 {
|
||||
t.Fatalf("expected 1 enqueued message, got %d", len(client.enqueuedMsgs))
|
||||
}
|
||||
|
||||
exception, ok := client.enqueuedMsgs[0].(Exception)
|
||||
if !ok {
|
||||
t.Fatalf("expected Exception, got %T", client.enqueuedMsgs[0])
|
||||
}
|
||||
|
||||
if exception.Properties == nil {
|
||||
t.Fatal("expected Properties to be set")
|
||||
}
|
||||
|
||||
expectedProps := map[string]interface{}{
|
||||
"environment": "production",
|
||||
"retry_count": int64(3),
|
||||
}
|
||||
|
||||
for key, expected := range expectedProps {
|
||||
if exception.Properties[key] != expected {
|
||||
t.Errorf("property %s: expected %v (type %T), got %v (type %T)",
|
||||
key, expected, expected, exception.Properties[key], exception.Properties[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
// signal to visually emphasize frames that are likely actionable.
|
||||
type InAppDecider func(frame runtime.Frame) bool
|
||||
|
||||
// SimpleInAppDecider a naive implementation of InAppDecider which checks the file paths.
|
||||
// It's good enough for 90% of the cases.
|
||||
// SimpleInAppDecider is a basic InAppDecider that classifies frames by file path.
|
||||
// It excludes vendored modules, module cache paths, and Go toolchain paths.
|
||||
func SimpleInAppDecider(frame runtime.Frame) bool {
|
||||
f := frame.File
|
||||
sep := string(filepath.Separator)
|
||||
@@ -44,9 +44,13 @@ type StackTraceExtractor interface {
|
||||
// of a StackTraceExtractor. It should be enough for most use cases, however, you're free to
|
||||
// create your own implementation if you require more flexibility.
|
||||
type DefaultStackTraceExtractor struct {
|
||||
// InAppDecider decides whether each runtime frame is application code.
|
||||
// Set it to SimpleInAppDecider or another non-nil function before use.
|
||||
InAppDecider InAppDecider
|
||||
}
|
||||
|
||||
// GetStackTrace captures the current goroutine stack and converts it to an ExceptionStacktrace.
|
||||
// The skip parameter omits leading runtime.Callers frames before conversion.
|
||||
func (d DefaultStackTraceExtractor) GetStackTrace(skip int) *ExceptionStacktrace {
|
||||
pcs := make([]uintptr, 64)
|
||||
stackCallCount := runtime.Callers(skip, pcs)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package insights
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -116,3 +117,155 @@ func TestException_Validate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestException_APIfy_WithCustomProperties(t *testing.T) {
|
||||
now := time.Now()
|
||||
exList := []ExceptionItem{{Type: "Error", Value: "Something went wrong"}}
|
||||
|
||||
fingerprintStr := "custom-fingerprint"
|
||||
|
||||
tests := map[string]struct {
|
||||
exception Exception
|
||||
expectKeys map[string]interface{}
|
||||
forbiddenValues []string
|
||||
}{
|
||||
"basic without custom properties": {
|
||||
exception: Exception{DistinctId: "user-123", Timestamp: now, ExceptionList: exList},
|
||||
expectKeys: map[string]interface{}{
|
||||
"$lib": SDKName,
|
||||
"$lib_version": getVersion(),
|
||||
"distinct_id": "user-123",
|
||||
},
|
||||
},
|
||||
"with custom properties and fingerprint": {
|
||||
exception: Exception{
|
||||
DistinctId: "user-123",
|
||||
Timestamp: now,
|
||||
ExceptionFingerprint: &fingerprintStr,
|
||||
Properties: NewProperties().Set("environment", "production").Set("retry_count", 3),
|
||||
ExceptionList: exList,
|
||||
},
|
||||
expectKeys: map[string]interface{}{
|
||||
"$lib": SDKName,
|
||||
"$lib_version": getVersion(),
|
||||
"distinct_id": "user-123",
|
||||
"$exception_fingerprint": "custom-fingerprint",
|
||||
"environment": "production",
|
||||
"retry_count": float64(3),
|
||||
},
|
||||
},
|
||||
"typed fields win on collision with custom": {
|
||||
exception: Exception{
|
||||
DistinctId: "user-123",
|
||||
Timestamp: now,
|
||||
Properties: NewProperties().Set("$lib", "custom-lib").Set("distinct_id", "custom-id"),
|
||||
ExceptionList: exList,
|
||||
},
|
||||
expectKeys: map[string]interface{}{
|
||||
"$lib": SDKName,
|
||||
"distinct_id": "user-123",
|
||||
},
|
||||
forbiddenValues: []string{"custom-lib", "custom-id"},
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
apiMsg, ok := tc.exception.APIfy().(ExceptionInApi)
|
||||
if !ok {
|
||||
t.Fatalf("expected ExceptionInApi, got %T", tc.exception.APIfy())
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(apiMsg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal failed: %v", err)
|
||||
}
|
||||
|
||||
var wire map[string]interface{}
|
||||
if err := json.Unmarshal(jsonBytes, &wire); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
props, ok := wire["properties"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("properties field missing or wrong type")
|
||||
}
|
||||
|
||||
for k, want := range tc.expectKeys {
|
||||
if got := props[k]; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("property %q: expected %v (%T), got %v (%T)", k, want, want, got, got)
|
||||
}
|
||||
}
|
||||
|
||||
for _, forbidden := range tc.forbiddenValues {
|
||||
for k, v := range props {
|
||||
if s, ok := v.(string); ok && s == forbidden {
|
||||
t.Errorf("forbidden value %q leaked into property %q", forbidden, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestException_JSONSerialization(t *testing.T) {
|
||||
fingerprint := "custom-fingerprint-123"
|
||||
exception := Exception{
|
||||
Type: "exception",
|
||||
Uuid: "01963f1a-3c12-7b21-8a1d-3f6d1cf49b8e",
|
||||
DistinctId: "user-123",
|
||||
Timestamp: time.Date(2024, 11, 17, 10, 30, 0, 0, time.UTC),
|
||||
Properties: NewProperties().Set("environment", "production").Set("custom_key", "custom_value"),
|
||||
DisableGeoIP: true,
|
||||
ExceptionFingerprint: &fingerprint,
|
||||
ExceptionList: []ExceptionItem{{Type: "RuntimeError", Value: "Database connection failed"}},
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(exception.APIfy())
|
||||
if err != nil {
|
||||
t.Fatalf("marshal failed: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(jsonBytes, &result); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
props, ok := result["properties"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("properties field missing or wrong type")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
obj map[string]interface{}
|
||||
field string
|
||||
expected interface{}
|
||||
}{
|
||||
{result, "type", "exception"},
|
||||
{result, "uuid", "01963f1a-3c12-7b21-8a1d-3f6d1cf49b8e"},
|
||||
{result, "event", "$exception"},
|
||||
{result, "library", SDKName},
|
||||
{result, "library_version", getVersion()},
|
||||
{props, "$lib", SDKName},
|
||||
{props, "$lib_version", getVersion()},
|
||||
{props, "distinct_id", "user-123"},
|
||||
{props, "$geoip_disable", true},
|
||||
{props, "$exception_fingerprint", "custom-fingerprint-123"},
|
||||
{props, "environment", "production"},
|
||||
{props, "custom_key", "custom_value"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
if tc.obj[tc.field] != tc.expected {
|
||||
t.Errorf("%s: expected %v, got %v", tc.field, tc.expected, tc.obj[tc.field])
|
||||
}
|
||||
}
|
||||
|
||||
exList, ok := props["$exception_list"].([]interface{})
|
||||
if !ok {
|
||||
t.Errorf("$exception_list: expected []interface{}, got %T", props["$exception_list"])
|
||||
}
|
||||
if len(exList) == 0 {
|
||||
t.Errorf("$exception_list: expected non-empty list, got empty")
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ The examples will automatically load configuration from a `.env` file if it exis
|
||||
# PostHog API Configuration
|
||||
POSTHOG_PROJECT_API_KEY=phc_your_project_api_key_here
|
||||
POSTHOG_PERSONAL_API_KEY=phx_your_personal_api_key_here
|
||||
POSTHOG_ENDPOINT=https://app.posthog.com
|
||||
POSTHOG_ENDPOINT=https://us.i.posthog.com
|
||||
```
|
||||
|
||||
**Note**: Environment variables take precedence over .env file values, so you can override specific settings when needed.
|
||||
@@ -26,7 +26,6 @@ func TestCapture(projectAPIKey, endpoint string) {
|
||||
Properties: map[string]interface{}{
|
||||
"application": "Insights Go",
|
||||
"version": "1.0.0",
|
||||
"platform": "macos",
|
||||
"file_size": "2.5MB",
|
||||
},
|
||||
}); err != nil {
|
||||
|
||||
@@ -38,6 +38,22 @@ func TestErrorTrackingThroughEnqueueing(projectAPIKey, endpoint string) {
|
||||
fmt.Println("error:", err)
|
||||
return
|
||||
}
|
||||
|
||||
exceptionWithProps := posthog.Exception{
|
||||
DistinctId: "distinct-id",
|
||||
Timestamp: time.Now(),
|
||||
Properties: posthog.Properties{
|
||||
"environment": "production",
|
||||
"retry_count": 3,
|
||||
},
|
||||
ExceptionList: []posthog.ExceptionItem{
|
||||
{Type: "Enqueued error with custom props", Value: "Error Description"},
|
||||
},
|
||||
}
|
||||
if err := client.Enqueue(exceptionWithProps); err != nil {
|
||||
fmt.Println("error:", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +73,7 @@ func TestErrorTrackingThroughLogHandler(projectAPIKey, endpoint string) {
|
||||
// for demo purposes, real applications should likely pull this value from the context.
|
||||
return "my-user-id"
|
||||
}),
|
||||
posthog.WithPropertiesFn(posthog.SlogAttrsAsProperties),
|
||||
))
|
||||
|
||||
done := time.After(3 * time.Second)
|
||||
@@ -71,6 +88,8 @@ func TestErrorTrackingThroughLogHandler(projectAPIKey, endpoint string) {
|
||||
case <-tick:
|
||||
log.Warn("Log that something broke",
|
||||
"error", fmt.Errorf("this is a dummy scenario"),
|
||||
"retry_count", 3,
|
||||
"endpoint", "/api/v1/users",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+48
-39
@@ -1,16 +1,31 @@
|
||||
package insights
|
||||
|
||||
// FeatureFlagPayload configures a single legacy feature flag evaluation.
|
||||
// It is used by Client.GetFeatureFlag, Client.IsFeatureEnabled,
|
||||
// Client.GetFeatureFlagResult, and Client.GetFeatureFlagPayload.
|
||||
type FeatureFlagPayload struct {
|
||||
Key string
|
||||
DistinctId string
|
||||
DeviceId *string
|
||||
Groups Groups
|
||||
PersonProperties Properties
|
||||
GroupProperties map[string]Properties
|
||||
OnlyEvaluateLocally bool
|
||||
// Key is the feature flag key to evaluate. It is required.
|
||||
Key string
|
||||
// DistinctId is the user distinct ID to evaluate the flag for. It is required.
|
||||
DistinctId string
|
||||
// DeviceId optionally provides a device_id for remote /flags requests and event deduplication.
|
||||
DeviceId *string
|
||||
// Groups supplies group identifiers for group-targeted flags.
|
||||
Groups Groups
|
||||
// PersonProperties overrides person properties used during flag evaluation.
|
||||
PersonProperties Properties
|
||||
// GroupProperties overrides group properties used during flag evaluation, keyed by group type.
|
||||
GroupProperties map[string]Properties
|
||||
// OnlyEvaluateLocally prevents fallback to remote /flags requests.
|
||||
OnlyEvaluateLocally bool
|
||||
// SendFeatureFlagEvents controls whether $feature_flag_called is captured.
|
||||
// Nil defaults to true during validation.
|
||||
SendFeatureFlagEvents *bool
|
||||
}
|
||||
|
||||
// trueVal is a package-level constant to avoid allocating a new bool pointer on every validate() call.
|
||||
var trueVal = true
|
||||
|
||||
func (c *FeatureFlagPayload) validate() error {
|
||||
if len(c.Key) == 0 {
|
||||
return ConfigError{
|
||||
@@ -28,32 +43,34 @@ func (c *FeatureFlagPayload) validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
if c.Groups == nil {
|
||||
c.Groups = Groups{}
|
||||
}
|
||||
|
||||
if c.PersonProperties == nil {
|
||||
c.PersonProperties = NewProperties()
|
||||
}
|
||||
|
||||
if c.GroupProperties == nil {
|
||||
c.GroupProperties = map[string]Properties{}
|
||||
}
|
||||
// Groups, PersonProperties, and GroupProperties are intentionally left nil.
|
||||
// Nil maps work correctly for local evaluation (nil map reads return zero values).
|
||||
// The remote fallback path in makeFlagsRequest handles nil→empty conversion
|
||||
// before JSON marshaling.
|
||||
|
||||
if c.SendFeatureFlagEvents == nil {
|
||||
tempTrue := true
|
||||
c.SendFeatureFlagEvents = &tempTrue
|
||||
c.SendFeatureFlagEvents = &trueVal
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FeatureFlagPayloadNoKey configures legacy evaluation of all flags for one user.
|
||||
// It is used by Client.GetAllFlags.
|
||||
type FeatureFlagPayloadNoKey struct {
|
||||
DistinctId string
|
||||
DeviceId *string
|
||||
Groups Groups
|
||||
PersonProperties Properties
|
||||
GroupProperties map[string]Properties
|
||||
OnlyEvaluateLocally bool
|
||||
// DistinctId is the user distinct ID to evaluate flags for. It is required.
|
||||
DistinctId string
|
||||
// DeviceId optionally provides a device_id for remote /flags requests and event deduplication.
|
||||
DeviceId *string
|
||||
// Groups supplies group identifiers for group-targeted flags.
|
||||
Groups Groups
|
||||
// PersonProperties overrides person properties used during flag evaluation.
|
||||
PersonProperties Properties
|
||||
// GroupProperties overrides group properties used during flag evaluation, keyed by group type.
|
||||
GroupProperties map[string]Properties
|
||||
// OnlyEvaluateLocally prevents fallback to remote /flags requests.
|
||||
OnlyEvaluateLocally bool
|
||||
// SendFeatureFlagEvents is reserved for parity with single-flag payloads.
|
||||
// Nil defaults to true during validation; GetAllFlags does not currently emit per-flag access events.
|
||||
SendFeatureFlagEvents *bool
|
||||
}
|
||||
|
||||
@@ -66,21 +83,13 @@ func (c *FeatureFlagPayloadNoKey) validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
if c.Groups == nil {
|
||||
c.Groups = Groups{}
|
||||
}
|
||||
|
||||
if c.PersonProperties == nil {
|
||||
c.PersonProperties = NewProperties()
|
||||
}
|
||||
|
||||
if c.GroupProperties == nil {
|
||||
c.GroupProperties = map[string]Properties{}
|
||||
}
|
||||
// Groups, PersonProperties, and GroupProperties are intentionally left nil.
|
||||
// Nil maps work correctly for local evaluation (nil map reads return zero values).
|
||||
// The remote fallback path in makeFlagsRequest handles nil→empty conversion
|
||||
// before JSON marshaling.
|
||||
|
||||
if c.SendFeatureFlagEvents == nil {
|
||||
tempTrue := true
|
||||
c.SendFeatureFlagEvents = &tempTrue
|
||||
c.SendFeatureFlagEvents = &trueVal
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -46,15 +46,18 @@ const (
|
||||
// APIError represents an HTTP API error with a status code.
|
||||
// This allows classifyError to generate api_error_{status} strings.
|
||||
type APIError struct {
|
||||
// StatusCode is the HTTP status code returned by the PostHog API.
|
||||
StatusCode int
|
||||
Message string
|
||||
// Message is the error message returned by or derived from the API response.
|
||||
Message string
|
||||
}
|
||||
|
||||
// Error returns the API error message.
|
||||
func (e *APIError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// NewAPIError creates a new APIError with the given status code.
|
||||
// NewAPIError creates an APIError with the given HTTP status code and message.
|
||||
func NewAPIError(statusCode int, message string) *APIError {
|
||||
return &APIError{
|
||||
StatusCode: statusCode,
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
package posthog
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// FeatureFlagEvaluations is a snapshot of feature-flag evaluations for a
|
||||
// single distinct_id, returned by Client.EvaluateFlags. It is the recommended
|
||||
// way to evaluate many flags for a single user with a single network request:
|
||||
// the snapshot can power any number of IsEnabled / GetFlag checks and can be
|
||||
// attached to a Capture event via Capture.Flags so the event carries the
|
||||
// exact values the application branched on, with no additional /flags call.
|
||||
//
|
||||
// Calls to IsEnabled and GetFlag fire $feature_flag_called (deduped per
|
||||
// (distinct_id, key, device_id)). GetFlagPayload does not fire an event.
|
||||
type FeatureFlagEvaluations struct {
|
||||
host featureFlagEvaluationsHost
|
||||
distinctId string
|
||||
deviceId *string
|
||||
groups Groups
|
||||
flags map[string]evaluatedFlagRecord
|
||||
requestId string
|
||||
evaluatedAt *int64
|
||||
flagDefinitionsLoadedAt *int64
|
||||
// errorsWhileComputing and quotaLimited capture response-level error
|
||||
// signals from the /flags request. They are combined with per-flag
|
||||
// errors in the $feature_flag_called event so consumers see the same
|
||||
// granularity the legacy single-flag path emits.
|
||||
errorsWhileComputing bool
|
||||
quotaLimited bool
|
||||
|
||||
mu sync.Mutex
|
||||
accessed map[string]struct{}
|
||||
}
|
||||
|
||||
// evaluatedFlagRecord is the per-flag entry stored in a snapshot. All fields
|
||||
// are optional except Key and Enabled, mirroring the v4 /flags response shape
|
||||
// with an additional LocallyEvaluated marker for poller-resolved flags.
|
||||
type evaluatedFlagRecord struct {
|
||||
Key string
|
||||
Enabled bool
|
||||
Variant *string
|
||||
Payload *string
|
||||
ID *int
|
||||
Version *int
|
||||
Reason *string
|
||||
LocallyEvaluated bool
|
||||
Error *string
|
||||
}
|
||||
|
||||
// featureFlagEvaluationsHost is the small callback surface a snapshot uses to
|
||||
// talk back to the client. It is intentionally narrow so tests can construct
|
||||
// snapshots without spinning up a full client. Warnings are routed through
|
||||
// the SDK's Logger; users who want them silenced should pass a Logger that
|
||||
// drops Warnf calls.
|
||||
type featureFlagEvaluationsHost struct {
|
||||
captureFlagCalledIfNeeded func(distinctId, key string, deviceId *string, properties Properties, groups Groups)
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// IsEnabled reports whether the flag is enabled for this snapshot's user.
|
||||
// Unknown flags return false. The first call for a given key fires
|
||||
// $feature_flag_called with full evaluation metadata; subsequent calls with
|
||||
// the same response are deduped against the client's per-distinct_id cache.
|
||||
func (e *FeatureFlagEvaluations) IsEnabled(key string) bool {
|
||||
if e == nil {
|
||||
return false
|
||||
}
|
||||
flag, ok := e.flags[key]
|
||||
e.recordAccess(key)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return flag.Enabled
|
||||
}
|
||||
|
||||
// GetFlag returns the value of the flag: a string for multivariate flags,
|
||||
// true or false for boolean flags, or nil for unknown flags. The first call
|
||||
// for a given key fires $feature_flag_called with full evaluation metadata;
|
||||
// subsequent calls with the same response are deduped.
|
||||
func (e *FeatureFlagEvaluations) GetFlag(key string) interface{} {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
flag, ok := e.flags[key]
|
||||
e.recordAccess(key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if !flag.Enabled {
|
||||
return false
|
||||
}
|
||||
if flag.Variant != nil {
|
||||
return *flag.Variant
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GetFlagPayload returns the raw JSON payload string for the flag, or "" if
|
||||
// no payload is configured or the flag is unknown. Unlike IsEnabled and
|
||||
// GetFlag, it does not record an access and does not fire an event.
|
||||
func (e *FeatureFlagEvaluations) GetFlagPayload(key string) string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
flag, ok := e.flags[key]
|
||||
if !ok || flag.Payload == nil {
|
||||
return ""
|
||||
}
|
||||
return *flag.Payload
|
||||
}
|
||||
|
||||
// Keys returns the sorted list of evaluated flag keys.
|
||||
func (e *FeatureFlagEvaluations) Keys() []string {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(e.flags))
|
||||
for k := range e.flags {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// OnlyAccessed returns a filtered copy of this snapshot containing only the
|
||||
// flags that were accessed via IsEnabled or GetFlag before this call.
|
||||
//
|
||||
// The method honors its name: if nothing has been accessed yet, the returned
|
||||
// snapshot is empty. Pre-access the flags you want to attach before calling.
|
||||
//
|
||||
// The returned snapshot tracks access independently from the receiver;
|
||||
// further accesses on the child do not influence what the parent sees.
|
||||
func (e *FeatureFlagEvaluations) OnlyAccessed() *FeatureFlagEvaluations {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
e.mu.Lock()
|
||||
filtered := make(map[string]evaluatedFlagRecord, len(e.accessed))
|
||||
for k := range e.accessed {
|
||||
if flag, ok := e.flags[k]; ok {
|
||||
filtered[k] = flag
|
||||
}
|
||||
}
|
||||
e.mu.Unlock()
|
||||
return e.cloneWith(filtered)
|
||||
}
|
||||
|
||||
// Only returns a filtered copy of this snapshot keeping only the named flags
|
||||
// that were actually evaluated. Unknown keys are dropped with a warning.
|
||||
//
|
||||
// The returned snapshot tracks access independently from the receiver.
|
||||
func (e *FeatureFlagEvaluations) Only(keys []string) *FeatureFlagEvaluations {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
filtered := make(map[string]evaluatedFlagRecord, len(keys))
|
||||
var missing []string
|
||||
for _, k := range keys {
|
||||
if flag, ok := e.flags[k]; ok {
|
||||
filtered[k] = flag
|
||||
} else {
|
||||
missing = append(missing, k)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 && e.host.logger != nil {
|
||||
e.host.logger.Warnf("FeatureFlagEvaluations.Only() was called with flag keys that are not in the evaluation set and will be dropped: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
return e.cloneWith(filtered)
|
||||
}
|
||||
|
||||
// eventProperties returns the $feature/<key> and $active_feature_flags
|
||||
// properties for the snapshot, suitable for merging into a Capture event.
|
||||
func (e *FeatureFlagEvaluations) eventProperties() Properties {
|
||||
props := NewProperties()
|
||||
if e == nil || len(e.flags) == 0 {
|
||||
return props
|
||||
}
|
||||
active := make([]string, 0, len(e.flags))
|
||||
for key, flag := range e.flags {
|
||||
var value interface{}
|
||||
switch {
|
||||
case !flag.Enabled:
|
||||
value = false
|
||||
case flag.Variant != nil:
|
||||
value = *flag.Variant
|
||||
default:
|
||||
value = true
|
||||
}
|
||||
props.Set("$feature/"+key, value)
|
||||
if flag.Enabled {
|
||||
active = append(active, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(active)
|
||||
if len(active) > 0 {
|
||||
props.Set("$active_feature_flags", active)
|
||||
}
|
||||
return props
|
||||
}
|
||||
|
||||
// recordAccess marks key as accessed, builds the $feature_flag_called event
|
||||
// properties, and forwards them to the client's dedup helper. Access from a
|
||||
// snapshot bound to an empty distinct_id is silently dropped — those events
|
||||
// would be useless and would pollute analytics with empty distinct_ids.
|
||||
func (e *FeatureFlagEvaluations) recordAccess(key string) {
|
||||
if e.distinctId == "" {
|
||||
return
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
if e.accessed == nil {
|
||||
e.accessed = make(map[string]struct{})
|
||||
}
|
||||
_, alreadyAccessed := e.accessed[key]
|
||||
e.accessed[key] = struct{}{}
|
||||
e.mu.Unlock()
|
||||
|
||||
if e.host.captureFlagCalledIfNeeded == nil {
|
||||
return
|
||||
}
|
||||
|
||||
flag, found := e.flags[key]
|
||||
|
||||
var response interface{}
|
||||
switch {
|
||||
case !found:
|
||||
response = nil
|
||||
case !flag.Enabled:
|
||||
response = false
|
||||
case flag.Variant != nil:
|
||||
response = *flag.Variant
|
||||
default:
|
||||
response = true
|
||||
}
|
||||
|
||||
properties := NewProperties().
|
||||
Set("$feature_flag", key).
|
||||
Set("$feature_flag_response", response).
|
||||
Set("$feature/"+key, response).
|
||||
Set("locally_evaluated", found && flag.LocallyEvaluated)
|
||||
|
||||
if e.deviceId != nil {
|
||||
properties.Set("$device_id", *e.deviceId)
|
||||
}
|
||||
if e.requestId != "" {
|
||||
properties.Set("$feature_flag_request_id", e.requestId)
|
||||
}
|
||||
if found {
|
||||
if flag.Payload != nil {
|
||||
properties.Set("$feature_flag_payload", *flag.Payload)
|
||||
}
|
||||
if flag.ID != nil {
|
||||
properties.Set("$feature_flag_id", *flag.ID)
|
||||
}
|
||||
if flag.Version != nil {
|
||||
properties.Set("$feature_flag_version", *flag.Version)
|
||||
}
|
||||
if flag.Reason != nil {
|
||||
properties.Set("$feature_flag_reason", *flag.Reason)
|
||||
}
|
||||
if flag.LocallyEvaluated {
|
||||
if e.flagDefinitionsLoadedAt != nil {
|
||||
properties.Set("$feature_flag_definitions_loaded_at", *e.flagDefinitionsLoadedAt)
|
||||
}
|
||||
} else if e.evaluatedAt != nil {
|
||||
properties.Set("$feature_flag_evaluated_at", *e.evaluatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// Build the comma-joined $feature_flag_error matching the single-flag
|
||||
// path's granularity: response-level errors (errors-while-computing,
|
||||
// quota-limited) are combined with per-flag errors so consumers can
|
||||
// filter by type.
|
||||
var errs []string
|
||||
if e.errorsWhileComputing {
|
||||
errs = append(errs, FeatureFlagErrorErrorsWhileComputing)
|
||||
}
|
||||
if e.quotaLimited {
|
||||
errs = append(errs, FeatureFlagErrorQuotaLimited)
|
||||
}
|
||||
if !found {
|
||||
errs = append(errs, FeatureFlagErrorFlagMissing)
|
||||
} else if flag.Error != nil {
|
||||
errs = append(errs, *flag.Error)
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
properties.Set("$feature_flag_error", strings.Join(errs, ","))
|
||||
}
|
||||
|
||||
// Dedup is owned by the client cache; alreadyAccessed is a per-snapshot
|
||||
// hint that lets us skip the client call when we know we have already
|
||||
// fired for this key from this snapshot.
|
||||
if alreadyAccessed {
|
||||
return
|
||||
}
|
||||
e.host.captureFlagCalledIfNeeded(e.distinctId, key, e.deviceId, properties, e.groups)
|
||||
}
|
||||
|
||||
// cloneWith builds a child snapshot with the given flag set. The accessed set
|
||||
// is copied so further access on the child does not leak back into the
|
||||
// parent's accessed set.
|
||||
func (e *FeatureFlagEvaluations) cloneWith(flags map[string]evaluatedFlagRecord) *FeatureFlagEvaluations {
|
||||
e.mu.Lock()
|
||||
accessedCopy := make(map[string]struct{}, len(e.accessed))
|
||||
for k := range e.accessed {
|
||||
accessedCopy[k] = struct{}{}
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
return &FeatureFlagEvaluations{
|
||||
host: e.host,
|
||||
distinctId: e.distinctId,
|
||||
deviceId: e.deviceId,
|
||||
groups: e.groups,
|
||||
flags: flags,
|
||||
requestId: e.requestId,
|
||||
evaluatedAt: e.evaluatedAt,
|
||||
flagDefinitionsLoadedAt: e.flagDefinitionsLoadedAt,
|
||||
errorsWhileComputing: e.errorsWhileComputing,
|
||||
quotaLimited: e.quotaLimited,
|
||||
accessed: accessedCopy,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
func TestFlagDependenciesSimpleChain(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(`{
|
||||
"flags": [
|
||||
{
|
||||
@@ -109,7 +109,7 @@ func TestFlagDependenciesSimpleChain(t *testing.T) {
|
||||
|
||||
func TestFlagDependenciesCircularDependency(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(`{
|
||||
"flags": [
|
||||
{
|
||||
@@ -193,7 +193,7 @@ func TestFlagDependenciesCircularDependency(t *testing.T) {
|
||||
|
||||
func TestFlagDependenciesMissingFlag(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(`{
|
||||
"flags": [
|
||||
{
|
||||
@@ -246,7 +246,7 @@ func TestFlagDependenciesMissingFlag(t *testing.T) {
|
||||
|
||||
func TestFlagDependenciesComplexChain(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(`{
|
||||
"flags": [
|
||||
{
|
||||
@@ -355,7 +355,7 @@ func TestFlagDependenciesComplexChain(t *testing.T) {
|
||||
|
||||
func TestFlagDependenciesMixedConditions(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(`{
|
||||
"flags": [
|
||||
{
|
||||
@@ -440,7 +440,7 @@ func TestFlagDependenciesMixedConditions(t *testing.T) {
|
||||
|
||||
func TestFlagDependenciesMalformedChain(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(`{
|
||||
"flags": [
|
||||
{
|
||||
@@ -508,7 +508,7 @@ func TestFlagDependenciesMalformedChain(t *testing.T) {
|
||||
// This test is equivalent to the EvaluatesMultiLevelMultivariateDependencyChain test in the .NET SDK
|
||||
func TestMultiLevelMultivariateDependencyChain(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(`{
|
||||
"flags": [
|
||||
{
|
||||
|
||||
@@ -82,7 +82,7 @@ func TestFlags(t *testing.T) {
|
||||
for _, test := range tests {
|
||||
test := test // Capture loop variable for Go 1.21 compatibility
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture(test.fixture)))
|
||||
}
|
||||
}))
|
||||
@@ -211,7 +211,7 @@ func TestFlags(t *testing.T) {
|
||||
|
||||
func TestFeatureFlagCalledIncludesDeviceId(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v4.json")))
|
||||
}
|
||||
}))
|
||||
@@ -366,7 +366,7 @@ func TestFeatureFlagErrorOnCapturedEvents(t *testing.T) {
|
||||
|
||||
t.Run("API error sets $feature_flag_error with status code", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"error": "Internal Server Error"}`))
|
||||
}
|
||||
@@ -432,7 +432,7 @@ func TestFeatureFlagErrorOnCapturedEvents(t *testing.T) {
|
||||
|
||||
func TestFlagsV4(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v4.json")))
|
||||
}
|
||||
}))
|
||||
@@ -553,7 +553,7 @@ func TestGetFeatureFlagResult(t *testing.T) {
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture(test.fixture)))
|
||||
}
|
||||
}))
|
||||
@@ -729,7 +729,7 @@ func TestGetFeatureFlagResult(t *testing.T) {
|
||||
|
||||
func TestGetFeatureFlagResultGetPayloadAs(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v4.json")))
|
||||
}
|
||||
}))
|
||||
@@ -778,7 +778,7 @@ func TestGetFeatureFlagResultGetPayloadAs(t *testing.T) {
|
||||
|
||||
func TestGetFeatureFlagResultReturnsErrorForNonExistentFlag(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
// Return empty flags - the requested flag won't exist
|
||||
w.Write([]byte(`{"flags": [], "group_type_mapping": {}}`))
|
||||
}
|
||||
@@ -812,7 +812,7 @@ func TestGetFeatureFlagResultReturnsErrorForNonExistentFlag(t *testing.T) {
|
||||
|
||||
func TestGetFeatureFlagResultPropagatesLocalEvaluationErrors(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
}))
|
||||
@@ -851,7 +851,7 @@ func TestGetFeatureFlagResultPropagatesLocalEvaluationErrors(t *testing.T) {
|
||||
|
||||
func TestGetFeatureFlagResultPropagatesRemoteAPIErrors(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"error": "Internal Server Error"}`))
|
||||
}
|
||||
@@ -882,3 +882,60 @@ func TestGetFeatureFlagResultPropagatesRemoteAPIErrors(t *testing.T) {
|
||||
t.Errorf("Expected result to be nil, got: %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhitespacePersonalAPIKeySkipsPollerAndUsesRemoteFlags(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
definitionsCalls := 0
|
||||
flagsCalls := 0
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/flags/definitions":
|
||||
mu.Lock()
|
||||
definitionsCalls++
|
||||
mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"flags": [], "group_type_mapping": {}}`))
|
||||
case "/flags", "/flags/":
|
||||
mu.Lock()
|
||||
flagsCalls++
|
||||
mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"featureFlags": {"any-flag": true}, "featureFlagPayloads": {}}`))
|
||||
default:
|
||||
t.Errorf("Unexpected request to %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewWithConfig("test-api-key", Config{
|
||||
Endpoint: server.URL,
|
||||
PersonalApiKey: " \n\t ",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
result, err := client.GetFeatureFlagResult(FeatureFlagPayload{
|
||||
Key: "any-flag",
|
||||
DistinctId: "some-distinct-id",
|
||||
SendFeatureFlagEvents: Ptr(false),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
if result == nil || !result.Enabled {
|
||||
t.Fatalf("Expected enabled flag result, got: %#v", result)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if definitionsCalls != 0 {
|
||||
t.Fatalf("Expected poller to be skipped, got %d /flags/definitions call(s)", definitionsCalls)
|
||||
}
|
||||
if flagsCalls != 1 {
|
||||
t.Fatalf("Expected one /flags call, got %d", flagsCalls)
|
||||
}
|
||||
}
|
||||
|
||||
+172
-61
@@ -9,6 +9,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -21,9 +22,9 @@ import (
|
||||
|
||||
func TestFlagPersonProperty(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("feature_flag/test-simple-flag-person-prop.json")))
|
||||
}
|
||||
}))
|
||||
@@ -63,7 +64,7 @@ func TestFlagPersonProperty(t *testing.T) {
|
||||
|
||||
func TestFlagGroup(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
var reqBody FlagsRequestData
|
||||
@@ -97,7 +98,7 @@ func TestFlagGroup(t *testing.T) {
|
||||
t.Errorf("Expected groupProperties to be map[company:map[name:Project Name 1]], got %s", reqBody.GroupProperties)
|
||||
}
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("feature_flag/test-flag-group-properties.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/batch/") {
|
||||
// Ignore batch requests
|
||||
@@ -181,9 +182,9 @@ func TestFlagGroupProperty(t *testing.T) {
|
||||
|
||||
func TestComplexDefinition(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("feature_flag/test-complex-definition.json"))) // Don't return anything for local eval
|
||||
}
|
||||
}))
|
||||
@@ -219,9 +220,9 @@ func TestComplexDefinition(t *testing.T) {
|
||||
|
||||
func TestFallbackToFlags(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte("{}")) // Don't return anything for local eval
|
||||
}
|
||||
}))
|
||||
@@ -248,9 +249,9 @@ func TestFallbackToFlags(t *testing.T) {
|
||||
|
||||
func TestFeatureFlagsDontFallbackToFlagsWhenOnlyLocalEvaluationIsTrue(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte("test-flags-v3.json"))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("feature_flag/test-feature-flags-dont-fallback-to-decide-when-only-local-evaluation-is-true.json")))
|
||||
}
|
||||
}))
|
||||
@@ -325,9 +326,9 @@ func TestFeatureFlagsDontFallbackToFlagsWhenOnlyLocalEvaluationIsTrue(t *testing
|
||||
|
||||
func TestFeatureFlagDefaultsDontHinderEvaluation(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("feature_flag/test-false.json")))
|
||||
}
|
||||
}))
|
||||
@@ -434,9 +435,9 @@ func TestFeatureFlagNullComeIntoPlayOnlyWhenFlagsErrorsOut(t *testing.T) {
|
||||
|
||||
func TestExperienceContinuityOverride(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("feature_flag/test-simple-flag.json")))
|
||||
}
|
||||
}))
|
||||
@@ -474,9 +475,9 @@ func TestExperienceContinuityOverride(t *testing.T) {
|
||||
|
||||
func TestGetAllFlags(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("feature_flag/test-multiple-flags.json")))
|
||||
}
|
||||
}))
|
||||
@@ -500,9 +501,9 @@ func TestGetAllFlags(t *testing.T) {
|
||||
|
||||
func TestGetAllFlagsEmptyLocal(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte("{}"))
|
||||
}
|
||||
}))
|
||||
@@ -528,9 +529,9 @@ func TestGetAllFlagsEmptyLocal(t *testing.T) {
|
||||
|
||||
func TestGetAllFlagsNoRemoteFallback(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("feature_flag/test-multiple-flags-valid.json")))
|
||||
}
|
||||
}))
|
||||
@@ -554,9 +555,9 @@ func TestGetAllFlagsNoRemoteFallback(t *testing.T) {
|
||||
|
||||
func TestGetAllFlagsOnlyLocalEvaluationSet(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("feature_flag/test-get-all-flags-with-fallback-but-only-local-evaluation-set.json")))
|
||||
}
|
||||
}))
|
||||
@@ -581,9 +582,9 @@ func TestGetAllFlagsOnlyLocalEvaluationSet(t *testing.T) {
|
||||
|
||||
func TestComputeInactiveFlagsLocally(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("feature_flag/test-compute-inactive-flags-locally.json")))
|
||||
}
|
||||
}))
|
||||
@@ -605,9 +606,9 @@ func TestComputeInactiveFlagsLocally(t *testing.T) {
|
||||
}
|
||||
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("feature_flag/test-compute-inactive-flags-locally-2.json")))
|
||||
}
|
||||
}))
|
||||
@@ -631,7 +632,7 @@ func TestComputeInactiveFlagsLocally(t *testing.T) {
|
||||
|
||||
func TestFeatureFlagWithDependencies(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("feature_flag/test-flag-with-dependencies.json")))
|
||||
}
|
||||
}))
|
||||
@@ -701,18 +702,37 @@ func TestFeatureEnabledSimpleIsTrueWhenRolloutUndefined(t *testing.T) {
|
||||
|
||||
func TestGetFeatureFlag(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("feature_flag/test-simple-flag-person-prop.json")))
|
||||
}
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
var capturedEvent *CaptureInApi
|
||||
var mu sync.Mutex
|
||||
eventCaptured := make(chan struct{}, 1)
|
||||
|
||||
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
|
||||
PersonalApiKey: "some very secret key",
|
||||
Endpoint: server.URL,
|
||||
BatchSize: 1,
|
||||
Callback: testCallback{
|
||||
func(m APIMessage) {
|
||||
if capture, ok := m.(CaptureInApi); ok {
|
||||
mu.Lock()
|
||||
capturedEvent = &capture
|
||||
mu.Unlock()
|
||||
select {
|
||||
case eventCaptured <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
},
|
||||
func(m APIMessage, e error) {},
|
||||
},
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
@@ -726,13 +746,104 @@ func TestGetFeatureFlag(t *testing.T) {
|
||||
if variant != "variant-1" {
|
||||
t.Error("Should match")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-eventCaptured:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Timed out waiting for captured event")
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
lastEvent := capturedEvent
|
||||
mu.Unlock()
|
||||
|
||||
if lastEvent == nil || lastEvent.Event != "$feature_flag_called" {
|
||||
t.Errorf("Expected a $feature_flag_called event, got: %v", lastEvent)
|
||||
}
|
||||
|
||||
if lastEvent != nil {
|
||||
if lastEvent.Properties["$feature_flag"] != "test-get-feature" {
|
||||
t.Errorf("Expected feature flag key 'test-get-feature', got: %v", lastEvent.Properties["$feature_flag"])
|
||||
}
|
||||
if lastEvent.Properties["$feature_flag_response"] != "variant-1" {
|
||||
t.Errorf("Expected feature flag response 'variant-1', got: %v", lastEvent.Properties["$feature_flag_response"])
|
||||
}
|
||||
if lastEvent.Properties["locally_evaluated"] != false {
|
||||
t.Errorf("Expected locally_evaluated to be false (test-get-feature is only in /flags response), got: %v", lastEvent.Properties["locally_evaluated"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFeatureFlagLocallyEvaluated(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("feature_flag/test-simple-flag-person-prop.json")))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var capturedEvent *CaptureInApi
|
||||
var mu sync.Mutex
|
||||
eventCaptured := make(chan struct{}, 1)
|
||||
|
||||
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
|
||||
PersonalApiKey: "some very secret key",
|
||||
Endpoint: server.URL,
|
||||
BatchSize: 1,
|
||||
Callback: testCallback{
|
||||
func(m APIMessage) {
|
||||
if capture, ok := m.(CaptureInApi); ok {
|
||||
mu.Lock()
|
||||
capturedEvent = &capture
|
||||
mu.Unlock()
|
||||
select {
|
||||
case eventCaptured <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
},
|
||||
func(m APIMessage, e error) {},
|
||||
},
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
value, err := client.GetFeatureFlag(FeatureFlagPayload{
|
||||
Key: "simple-flag",
|
||||
DistinctId: "distinct_id",
|
||||
PersonProperties: NewProperties().Set("region", "USA"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if value != true {
|
||||
t.Errorf("Expected simple-flag to evaluate to true, got: %v", value)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-eventCaptured:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Timed out waiting for captured event")
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
lastEvent := capturedEvent
|
||||
mu.Unlock()
|
||||
|
||||
if lastEvent == nil || lastEvent.Event != "$feature_flag_called" {
|
||||
t.Fatalf("Expected a $feature_flag_called event, got: %v", lastEvent)
|
||||
}
|
||||
if lastEvent.Properties["locally_evaluated"] != true {
|
||||
t.Errorf("Expected locally_evaluated to be true for local evaluation, got: %v", lastEvent.Properties["locally_evaluated"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFeatureFlagPayload(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("feature_flag/test-simple-flag-person-prop.json")))
|
||||
}
|
||||
}))
|
||||
@@ -786,9 +897,9 @@ func TestGetRemoteConfigPayload(t *testing.T) {
|
||||
func TestFlagWithVariantOverrides(t *testing.T) {
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("feature_flag/test-variant-override.json")))
|
||||
}
|
||||
}))
|
||||
@@ -850,9 +961,9 @@ func TestFlagWithVariantOverrides(t *testing.T) {
|
||||
|
||||
func TestFlagWithClashingVariantOverrides(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("feature_flag/test-variant-override-clashing.json")))
|
||||
}
|
||||
}))
|
||||
@@ -916,9 +1027,9 @@ func TestFlagWithClashingVariantOverrides(t *testing.T) {
|
||||
|
||||
func TestFlagWithInvalidVariantOverrides(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("feature_flag/test-variant-override-invalid.json")))
|
||||
}
|
||||
}))
|
||||
@@ -980,9 +1091,9 @@ func TestFlagWithInvalidVariantOverrides(t *testing.T) {
|
||||
|
||||
func TestConditionsEvaluatedInOrder(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("feature_flag/test-condition-order.json")))
|
||||
}
|
||||
}))
|
||||
@@ -1051,9 +1162,9 @@ func TestConditionsEvaluatedInOrder(t *testing.T) {
|
||||
|
||||
func TestCaptureIsCalled(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("feature_flag/test-simple-flag-person-prop.json")))
|
||||
}
|
||||
}))
|
||||
@@ -4169,7 +4280,7 @@ func TestMultivariateFlagConsistencyPayload(t *testing.T) {
|
||||
|
||||
func TestFlagsFetchFail(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
} else if strings.HasPrefix(r.URL.Path, "/batch/") {
|
||||
// ignore batch requests
|
||||
@@ -4196,10 +4307,10 @@ func TestFlagsFetchFail(t *testing.T) {
|
||||
|
||||
func TestFlagWithTimeoutExceeded(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
time.Sleep(1 * time.Second)
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("feature_flag/test-flag-group-properties.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/batch/") {
|
||||
// Ignore batch requests
|
||||
@@ -4279,9 +4390,9 @@ func TestFlagDefinitionsWithTimeoutExceeded(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
// Sleep longer than client timeout (100ms) to trigger timeout
|
||||
time.Sleep(1 * time.Second)
|
||||
w.Write([]byte(fixture("feature_flag/test-flag-group-properties.json")))
|
||||
@@ -4340,20 +4451,20 @@ func TestFetchFlagsFails(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
|
||||
cli, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
|
||||
PersonalApiKey: "some very secret key",
|
||||
Endpoint: server.URL,
|
||||
})
|
||||
defer client.Close()
|
||||
defer cli.Close()
|
||||
|
||||
_, err := client.GetFeatureFlags()
|
||||
_, err := cli.(*client).featureFlagsPoller.GetFeatureFlags()
|
||||
if err != nil {
|
||||
t.Error("Should not fail", err)
|
||||
}
|
||||
client.ReloadFeatureFlags()
|
||||
client.ReloadFeatureFlags()
|
||||
cli.ReloadFeatureFlags()
|
||||
cli.ReloadFeatureFlags()
|
||||
|
||||
_, err = client.GetAllFlags(FeatureFlagPayloadNoKey{
|
||||
_, err = cli.GetAllFlags(FeatureFlagPayloadNoKey{
|
||||
DistinctId: "my-id",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -4375,7 +4486,7 @@ func TestFetchFlagsFails(t *testing.T) {
|
||||
// Check link: https://github.com/PostHog/posthog/blob/0bb3ed063c37f5be280e4283a0d2a6a6683a9534/rust/feature-flags/src/api/request_handler.rs#L1412
|
||||
func TestFeatureFlagWithOverrides(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("feature_flag/test-group-props.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/batch/") {
|
||||
// ignore
|
||||
@@ -4456,7 +4567,7 @@ func TestFeatureFlagWithOverrides(t *testing.T) {
|
||||
|
||||
func TestFeatureFlagDistinctIDOverride(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("feature_flag/test-distinct-id-local.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/batch/") {
|
||||
// ignore
|
||||
@@ -4519,7 +4630,7 @@ func TestFeatureFlagDistinctIDOverride(t *testing.T) {
|
||||
|
||||
func TestFeatureFlagDeviceIDBucketingLocalEvaluation(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("feature_flag/test-device-id-bucketing.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/batch/") {
|
||||
// ignore
|
||||
@@ -4553,7 +4664,7 @@ func TestFeatureFlagDeviceIDBucketingLocalEvaluation(t *testing.T) {
|
||||
|
||||
func TestFeatureFlagWithFalseVariant(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("feature_flag/test-false-variant.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/batch/") {
|
||||
// ignore
|
||||
@@ -4948,11 +5059,11 @@ func TestFallbackToAPIWhenFlagHasStaticCohortInMultiCondition(t *testing.T) {
|
||||
|
||||
var flagsAPICalled bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
// Return API response indicating user is in the static cohort
|
||||
flagsAPICalled = true
|
||||
w.Write([]byte(`{"featureFlags": {"multi-condition-flag": "set-1"}}`))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
// Return local evaluation data WITHOUT cohort 999 (making it a static cohort)
|
||||
w.Write([]byte(`{
|
||||
"flags": [
|
||||
@@ -5036,11 +5147,11 @@ func TestGetFeatureFlagPayloadFallbackToAPIWhenFlagHasStaticCohort(t *testing.T)
|
||||
|
||||
var flagsAPICalled bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
// Return API response with payload
|
||||
flagsAPICalled = true
|
||||
w.Write([]byte(`{"featureFlags": {"flag-with-cohort": "variant-a"}, "featureFlagPayloads": {"flag-with-cohort": "{\"message\": \"from-api\"}"}}`))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
// Return local evaluation data WITHOUT cohort 999 (making it a static cohort)
|
||||
w.Write([]byte(`{
|
||||
"flags": [
|
||||
@@ -5107,7 +5218,7 @@ func TestGetFeatureFlagPayloadFallbackToAPIWhenFlagHasStaticCohort(t *testing.T)
|
||||
|
||||
func TestDateBeforeOperatorAbsolute(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
response := `{
|
||||
"flags": [
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ package insights
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -556,3 +557,751 @@ func TestMatchPropertyDateComparison(t *testing.T) {
|
||||
require.False(t, isMatch)
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseSemver(t *testing.T) {
|
||||
t.Run("basic parsing", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
input string
|
||||
expected semverTuple
|
||||
}{
|
||||
{"1.2.3", semverTuple{1, 2, 3}},
|
||||
{"0.0.0", semverTuple{0, 0, 0}},
|
||||
{"10.20.30", semverTuple{10, 20, 30}},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
result, err := parseSemver(tc.input)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("v prefix", func(t *testing.T) {
|
||||
result, err := parseSemver("v1.2.3")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, semverTuple{1, 2, 3}, result)
|
||||
|
||||
result, err = parseSemver("V1.2.3")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, semverTuple{1, 2, 3}, result)
|
||||
})
|
||||
|
||||
t.Run("whitespace", func(t *testing.T) {
|
||||
result, err := parseSemver(" 1.2.3 ")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, semverTuple{1, 2, 3}, result)
|
||||
|
||||
result, err = parseSemver(" v1.2.3 ")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, semverTuple{1, 2, 3}, result)
|
||||
})
|
||||
|
||||
t.Run("pre-release suffixes stripped", func(t *testing.T) {
|
||||
result, err := parseSemver("1.2.3-alpha")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, semverTuple{1, 2, 3}, result)
|
||||
|
||||
result, err = parseSemver("1.2.3-alpha.1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, semverTuple{1, 2, 3}, result)
|
||||
|
||||
result, err = parseSemver("1.2.3-rc.1+build.123")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, semverTuple{1, 2, 3}, result)
|
||||
})
|
||||
|
||||
t.Run("build metadata stripped", func(t *testing.T) {
|
||||
result, err := parseSemver("1.2.3+build.123")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, semverTuple{1, 2, 3}, result)
|
||||
})
|
||||
|
||||
t.Run("partial versions default to zero", func(t *testing.T) {
|
||||
result, err := parseSemver("1.2")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, semverTuple{1, 2, 0}, result)
|
||||
|
||||
result, err = parseSemver("1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, semverTuple{1, 0, 0}, result)
|
||||
})
|
||||
|
||||
t.Run("extra components ignored", func(t *testing.T) {
|
||||
result, err := parseSemver("1.2.3.4")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, semverTuple{1, 2, 3}, result)
|
||||
|
||||
result, err = parseSemver("1.2.3.4.5.6")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, semverTuple{1, 2, 3}, result)
|
||||
})
|
||||
|
||||
t.Run("leading zeros rejected per semver 2.0.0 §2", func(t *testing.T) {
|
||||
invalidCases := []string{
|
||||
"01.2.3",
|
||||
"1.02.3",
|
||||
"1.2.03",
|
||||
"01.02.03",
|
||||
"1.07.3",
|
||||
"001.2.3",
|
||||
}
|
||||
for _, input := range invalidCases {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
_, err := parseSemver(input)
|
||||
require.Error(t, err, "expected error for input: %s", input)
|
||||
})
|
||||
}
|
||||
|
||||
// A literal "0" component is fine
|
||||
validCases := []string{"0.1.0", "1.0.0", "0.0.0"}
|
||||
for _, input := range validCases {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
_, err := parseSemver(input)
|
||||
require.NoError(t, err, "expected no error for valid input: %s", input)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid values", func(t *testing.T) {
|
||||
invalidCases := []string{
|
||||
"",
|
||||
" ",
|
||||
"v",
|
||||
"abc",
|
||||
"1.2.abc",
|
||||
".1.2.3",
|
||||
"a.b.c",
|
||||
}
|
||||
|
||||
for _, input := range invalidCases {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
_, err := parseSemver(input)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty components rejected", func(t *testing.T) {
|
||||
// These malformed inputs should be rejected, not silently parsed
|
||||
invalidCases := []string{
|
||||
"1..3", // empty minor
|
||||
"1.2.", // empty patch (trailing dot)
|
||||
"1.", // trailing dot with no minor
|
||||
"1.2..", // double trailing dot
|
||||
".1.2", // leading dot (empty major)
|
||||
}
|
||||
|
||||
for _, input := range invalidCases {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
_, err := parseSemver(input)
|
||||
require.Error(t, err, "expected error for input: %s", input)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed pre-release inputs rejected", func(t *testing.T) {
|
||||
// Inputs where the core version is malformed should be rejected
|
||||
invalidCases := []string{
|
||||
"-alpha", // no version before pre-release
|
||||
"+build", // no version before build metadata
|
||||
"1.-2.3", // negative in wrong position (looks like suffix delimiter)
|
||||
}
|
||||
|
||||
for _, input := range invalidCases {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
_, err := parseSemver(input)
|
||||
require.Error(t, err, "expected error for input: %s", input)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMatchPropertySemverEq(t *testing.T) {
|
||||
t.Run("exact match", func(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "version",
|
||||
Value: "1.2.3",
|
||||
Operator: "semver_eq",
|
||||
}
|
||||
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("version", "1.2.3"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, isMatch)
|
||||
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("version", "1.2.4"))
|
||||
require.NoError(t, err)
|
||||
require.False(t, isMatch)
|
||||
})
|
||||
|
||||
t.Run("pre-release equals base version", func(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "version",
|
||||
Value: "1.2.3",
|
||||
Operator: "semver_eq",
|
||||
}
|
||||
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("version", "1.2.3-alpha.1"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, isMatch)
|
||||
})
|
||||
|
||||
t.Run("partial versions", func(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "version",
|
||||
Value: "1.2",
|
||||
Operator: "semver_eq",
|
||||
}
|
||||
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("version", "1.2.0"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, isMatch)
|
||||
})
|
||||
|
||||
t.Run("v prefix", func(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "version",
|
||||
Value: "v1.2.3",
|
||||
Operator: "semver_eq",
|
||||
}
|
||||
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("version", "1.2.3"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, isMatch)
|
||||
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("version", "v1.2.3"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, isMatch)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMatchPropertySemverNeq(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "version",
|
||||
Value: "1.2.3",
|
||||
Operator: "semver_neq",
|
||||
}
|
||||
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("version", "1.2.3"))
|
||||
require.NoError(t, err)
|
||||
require.False(t, isMatch)
|
||||
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("version", "1.2.4"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, isMatch)
|
||||
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("version", "2.0.0"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, isMatch)
|
||||
}
|
||||
|
||||
func TestMatchPropertySemverGt(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "version",
|
||||
Value: "1.2.3",
|
||||
Operator: "semver_gt",
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
version string
|
||||
expected bool
|
||||
}{
|
||||
{"1.2.4", true},
|
||||
{"1.3.0", true},
|
||||
{"2.0.0", true},
|
||||
{"1.2.3", false},
|
||||
{"1.2.2", false},
|
||||
{"1.1.9", false},
|
||||
{"0.9.9", false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.version, func(t *testing.T) {
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expected, isMatch)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchPropertySemverGte(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "version",
|
||||
Value: "1.2.3",
|
||||
Operator: "semver_gte",
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
version string
|
||||
expected bool
|
||||
}{
|
||||
{"1.2.4", true},
|
||||
{"1.3.0", true},
|
||||
{"2.0.0", true},
|
||||
{"1.2.3", true}, // Equal
|
||||
{"1.2.2", false},
|
||||
{"1.1.9", false},
|
||||
{"0.9.9", false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.version, func(t *testing.T) {
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expected, isMatch)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchPropertySemverLt(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "version",
|
||||
Value: "1.2.3",
|
||||
Operator: "semver_lt",
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
version string
|
||||
expected bool
|
||||
}{
|
||||
{"1.2.2", true},
|
||||
{"1.1.9", true},
|
||||
{"0.9.9", true},
|
||||
{"1.2.3", false}, // Equal
|
||||
{"1.2.4", false},
|
||||
{"1.3.0", false},
|
||||
{"2.0.0", false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.version, func(t *testing.T) {
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expected, isMatch)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchPropertySemverLte(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "version",
|
||||
Value: "1.2.3",
|
||||
Operator: "semver_lte",
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
version string
|
||||
expected bool
|
||||
}{
|
||||
{"1.2.2", true},
|
||||
{"1.1.9", true},
|
||||
{"0.9.9", true},
|
||||
{"1.2.3", true}, // Equal
|
||||
{"1.2.4", false},
|
||||
{"1.3.0", false},
|
||||
{"2.0.0", false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.version, func(t *testing.T) {
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expected, isMatch)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchPropertySemverTilde(t *testing.T) {
|
||||
// ~1.2.3 means >=1.2.3 and <1.3.0
|
||||
property := FlagProperty{
|
||||
Key: "version",
|
||||
Value: "1.2.3",
|
||||
Operator: "semver_tilde",
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
version string
|
||||
expected bool
|
||||
}{
|
||||
// In range
|
||||
{"1.2.3", true}, // Lower bound (inclusive)
|
||||
{"1.2.4", true}, // Within range
|
||||
{"1.2.99", true}, // Near upper bound
|
||||
// Out of range
|
||||
{"1.3.0", false}, // Upper bound (exclusive)
|
||||
{"1.2.2", false}, // Below lower bound
|
||||
{"1.1.0", false}, // Too low
|
||||
{"2.0.0", false}, // Too high
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.version, func(t *testing.T) {
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expected, isMatch)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("tilde with partial version", func(t *testing.T) {
|
||||
// ~1.2 means >=1.2.0 and <1.3.0
|
||||
property := FlagProperty{
|
||||
Key: "version",
|
||||
Value: "1.2",
|
||||
Operator: "semver_tilde",
|
||||
}
|
||||
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("version", "1.2.0"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, isMatch)
|
||||
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("version", "1.2.5"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, isMatch)
|
||||
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("version", "1.3.0"))
|
||||
require.NoError(t, err)
|
||||
require.False(t, isMatch)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMatchPropertySemverCaret(t *testing.T) {
|
||||
t.Run("major > 0: ^1.2.3 means >=1.2.3 <2.0.0", func(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "version",
|
||||
Value: "1.2.3",
|
||||
Operator: "semver_caret",
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
version string
|
||||
expected bool
|
||||
}{
|
||||
{"1.2.3", true}, // Lower bound (inclusive)
|
||||
{"1.2.4", true}, // Within range
|
||||
{"1.3.0", true}, // Within range
|
||||
{"1.99.99", true}, // Near upper bound
|
||||
{"2.0.0", false}, // Upper bound (exclusive)
|
||||
{"1.2.2", false}, // Below lower bound
|
||||
{"0.9.9", false}, // Too low
|
||||
{"3.0.0", false}, // Too high
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.version, func(t *testing.T) {
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expected, isMatch)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("major == 0, minor > 0: ^0.2.3 means >=0.2.3 <0.3.0", func(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "version",
|
||||
Value: "0.2.3",
|
||||
Operator: "semver_caret",
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
version string
|
||||
expected bool
|
||||
}{
|
||||
{"0.2.3", true}, // Lower bound (inclusive)
|
||||
{"0.2.4", true}, // Within range
|
||||
{"0.2.99", true}, // Near upper bound
|
||||
{"0.3.0", false}, // Upper bound (exclusive)
|
||||
{"0.2.2", false}, // Below lower bound
|
||||
{"0.1.9", false}, // Too low
|
||||
{"1.0.0", false}, // Too high
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.version, func(t *testing.T) {
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expected, isMatch)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("major == 0, minor == 0: ^0.0.3 means >=0.0.3 <0.0.4", func(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "version",
|
||||
Value: "0.0.3",
|
||||
Operator: "semver_caret",
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
version string
|
||||
expected bool
|
||||
}{
|
||||
{"0.0.3", true}, // Lower bound (inclusive)
|
||||
{"0.0.4", false}, // Upper bound (exclusive)
|
||||
{"0.0.2", false}, // Below lower bound
|
||||
{"0.1.0", false}, // Too high
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.version, func(t *testing.T) {
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expected, isMatch)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMatchPropertySemverWildcard(t *testing.T) {
|
||||
t.Run("1.* means >=1.0.0 <2.0.0", func(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "version",
|
||||
Value: "1.*",
|
||||
Operator: "semver_wildcard",
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
version string
|
||||
expected bool
|
||||
}{
|
||||
{"1.0.0", true}, // Lower bound (inclusive)
|
||||
{"1.0.1", true}, // Within range
|
||||
{"1.5.0", true}, // Within range
|
||||
{"1.99.99", true}, // Near upper bound
|
||||
{"2.0.0", false}, // Upper bound (exclusive)
|
||||
{"0.9.9", false}, // Too low
|
||||
{"3.0.0", false}, // Too high
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.version, func(t *testing.T) {
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expected, isMatch)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("1.2.* means >=1.2.0 <1.3.0", func(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "version",
|
||||
Value: "1.2.*",
|
||||
Operator: "semver_wildcard",
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
version string
|
||||
expected bool
|
||||
}{
|
||||
{"1.2.0", true}, // Lower bound (inclusive)
|
||||
{"1.2.5", true}, // Within range
|
||||
{"1.2.99", true}, // Near upper bound
|
||||
{"1.3.0", false}, // Upper bound (exclusive)
|
||||
{"1.1.9", false}, // Too low
|
||||
{"2.0.0", false}, // Too high
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.version, func(t *testing.T) {
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expected, isMatch)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wildcard without asterisk treated as major version match", func(t *testing.T) {
|
||||
// Test that "1" alone matches same as "1.*"
|
||||
property := FlagProperty{
|
||||
Key: "version",
|
||||
Value: "1",
|
||||
Operator: "semver_wildcard",
|
||||
}
|
||||
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("version", "1.5.0"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, isMatch)
|
||||
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("version", "2.0.0"))
|
||||
require.NoError(t, err)
|
||||
require.False(t, isMatch)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMatchPropertySemverErrorHandling(t *testing.T) {
|
||||
t.Run("missing property key", func(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "version",
|
||||
Value: "1.2.3",
|
||||
Operator: "semver_eq",
|
||||
}
|
||||
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("other_key", "1.2.3"))
|
||||
require.Error(t, err)
|
||||
require.False(t, isMatch)
|
||||
|
||||
var inconclusiveErr *InconclusiveMatchError
|
||||
require.True(t, errors.As(err, &inconclusiveErr))
|
||||
})
|
||||
|
||||
t.Run("null/nil property value", func(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "version",
|
||||
Value: "1.2.3",
|
||||
Operator: "semver_eq",
|
||||
}
|
||||
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("version", nil))
|
||||
require.Error(t, err)
|
||||
require.False(t, isMatch)
|
||||
})
|
||||
|
||||
t.Run("non-string property value", func(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "version",
|
||||
Value: "1.2.3",
|
||||
Operator: "semver_eq",
|
||||
}
|
||||
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("version", 123))
|
||||
require.Error(t, err)
|
||||
require.False(t, isMatch)
|
||||
|
||||
var inconclusiveErr *InconclusiveMatchError
|
||||
require.True(t, errors.As(err, &inconclusiveErr))
|
||||
})
|
||||
|
||||
t.Run("invalid semver in property value", func(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "version",
|
||||
Value: "1.2.3",
|
||||
Operator: "semver_eq",
|
||||
}
|
||||
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("version", "not-a-semver"))
|
||||
require.Error(t, err)
|
||||
require.False(t, isMatch)
|
||||
|
||||
var inconclusiveErr *InconclusiveMatchError
|
||||
require.True(t, errors.As(err, &inconclusiveErr))
|
||||
})
|
||||
|
||||
t.Run("invalid semver in flag value", func(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "version",
|
||||
Value: "not-a-semver",
|
||||
Operator: "semver_eq",
|
||||
}
|
||||
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("version", "1.2.3"))
|
||||
require.Error(t, err)
|
||||
require.False(t, isMatch)
|
||||
|
||||
var inconclusiveErr *InconclusiveMatchError
|
||||
require.True(t, errors.As(err, &inconclusiveErr))
|
||||
})
|
||||
|
||||
t.Run("empty string semver", func(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "version",
|
||||
Value: "1.2.3",
|
||||
Operator: "semver_eq",
|
||||
}
|
||||
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("version", ""))
|
||||
require.Error(t, err)
|
||||
require.False(t, isMatch)
|
||||
})
|
||||
|
||||
t.Run("invalid wildcard pattern", func(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "version",
|
||||
Value: "*",
|
||||
Operator: "semver_wildcard",
|
||||
}
|
||||
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("version", "1.2.3"))
|
||||
require.Error(t, err)
|
||||
require.False(t, isMatch)
|
||||
})
|
||||
|
||||
t.Run("leading-zero property value rejected", func(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "version",
|
||||
Value: "1.2.3",
|
||||
Operator: "semver_eq",
|
||||
}
|
||||
|
||||
for _, badValue := range []string{"01.2.3", "1.02.3", "1.2.03", "1.07.3", "001.2.3"} {
|
||||
t.Run(badValue, func(t *testing.T) {
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("version", badValue))
|
||||
require.Error(t, err)
|
||||
require.False(t, isMatch)
|
||||
|
||||
var inconclusiveErr *InconclusiveMatchError
|
||||
require.True(t, errors.As(err, &inconclusiveErr))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("leading-zero flag value rejected for caret/tilde/wildcard", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
operator string
|
||||
value string
|
||||
}{
|
||||
{"semver_caret", "1.07.0"},
|
||||
{"semver_tilde", "1.07.0"},
|
||||
{"semver_wildcard", "01.*"},
|
||||
{"semver_gt", "01.2.3"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.operator+"/"+c.value, func(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "version",
|
||||
Value: c.value,
|
||||
Operator: c.operator,
|
||||
}
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("version", "2.0.0"))
|
||||
require.Error(t, err)
|
||||
require.False(t, isMatch)
|
||||
|
||||
var inconclusiveErr *InconclusiveMatchError
|
||||
require.True(t, errors.As(err, &inconclusiveErr))
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSemverCompareTo(t *testing.T) {
|
||||
testCases := []struct {
|
||||
a semverTuple
|
||||
b semverTuple
|
||||
expected int
|
||||
}{
|
||||
// Equal
|
||||
{semverTuple{1, 2, 3}, semverTuple{1, 2, 3}, 0},
|
||||
{semverTuple{0, 0, 0}, semverTuple{0, 0, 0}, 0},
|
||||
// Major difference
|
||||
{semverTuple{2, 0, 0}, semverTuple{1, 9, 9}, 1},
|
||||
{semverTuple{1, 9, 9}, semverTuple{2, 0, 0}, -1},
|
||||
// Minor difference
|
||||
{semverTuple{1, 3, 0}, semverTuple{1, 2, 9}, 1},
|
||||
{semverTuple{1, 2, 9}, semverTuple{1, 3, 0}, -1},
|
||||
// Patch difference
|
||||
{semverTuple{1, 2, 4}, semverTuple{1, 2, 3}, 1},
|
||||
{semverTuple{1, 2, 3}, semverTuple{1, 2, 4}, -1},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
name := tc.a.String() + " vs " + tc.b.String()
|
||||
t.Run(name, func(t *testing.T) {
|
||||
result := tc.a.compareTo(tc.b)
|
||||
require.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for test naming
|
||||
func (s semverTuple) String() string {
|
||||
return strconv.Itoa(s.major) + "." + strconv.Itoa(s.minor) + "." + strconv.Itoa(s.patch)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package posthog
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Mixed-targeting flag: flag-level aggregation is null, the first condition targets
|
||||
// group "company" (index 0), the second targets persons by email.
|
||||
const mixedTargetingFlagJSON = `{
|
||||
"flags": [{
|
||||
"id": 1,
|
||||
"key": "mixed-flag",
|
||||
"active": true,
|
||||
"filters": {
|
||||
"aggregation_group_type_index": null,
|
||||
"groups": [
|
||||
{
|
||||
"aggregation_group_type_index": 0,
|
||||
"properties": [
|
||||
{"key": "plan", "value": "enterprise", "operator": "exact", "type": "group", "group_type_index": 0}
|
||||
],
|
||||
"rollout_percentage": 100
|
||||
},
|
||||
{
|
||||
"aggregation_group_type_index": null,
|
||||
"properties": [
|
||||
{"key": "email", "value": "[email protected]", "operator": "exact", "type": "person"}
|
||||
],
|
||||
"rollout_percentage": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
}],
|
||||
"group_type_mapping": {"0": "company"},
|
||||
"cohorts": {}
|
||||
}`
|
||||
|
||||
const onlyGroupConditionFlagJSON = `{
|
||||
"flags": [{
|
||||
"id": 1,
|
||||
"key": "only-group-flag",
|
||||
"active": true,
|
||||
"filters": {
|
||||
"aggregation_group_type_index": null,
|
||||
"groups": [
|
||||
{
|
||||
"aggregation_group_type_index": 0,
|
||||
"properties": [
|
||||
{"key": "plan", "value": "enterprise", "operator": "exact", "type": "group", "group_type_index": 0}
|
||||
],
|
||||
"rollout_percentage": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
}],
|
||||
"group_type_mapping": {"0": "company"},
|
||||
"cohorts": {}
|
||||
}`
|
||||
|
||||
func newMixedTargetingServer(t *testing.T, localFlagsJSON string, decideCalls *atomic.Int32) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Order matters: /flags/definitions must be checked before /flags or /flags/
|
||||
// so the prefix doesn't accidentally match the decide endpoint.
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(localFlagsJSON))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
if decideCalls != nil {
|
||||
decideCalls.Add(1)
|
||||
}
|
||||
// Return a value that would clearly differ from local-eval result if we ever fall back.
|
||||
w.Write([]byte(`{"featureFlags": {"mixed-flag": "server-fallback", "only-group-flag": "server-fallback"}}`))
|
||||
return
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func TestMixedTargetingLocalEvaluation(t *testing.T) {
|
||||
type opts struct {
|
||||
groups Groups
|
||||
personProperties Properties
|
||||
groupProperties map[string]Properties
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
flagJSON string
|
||||
flagKey string
|
||||
opts opts
|
||||
expectVal interface{}
|
||||
}{
|
||||
{
|
||||
name: "person condition matches when no groups passed",
|
||||
flagJSON: mixedTargetingFlagJSON,
|
||||
flagKey: "mixed-flag",
|
||||
opts: opts{personProperties: Properties{"email": "[email protected]"}},
|
||||
expectVal: true,
|
||||
},
|
||||
{
|
||||
name: "group condition matches when group props match",
|
||||
flagJSON: mixedTargetingFlagJSON,
|
||||
flagKey: "mixed-flag",
|
||||
opts: opts{
|
||||
groups: Groups{"company": "acme"},
|
||||
groupProperties: map[string]Properties{"company": {"plan": "enterprise"}},
|
||||
personProperties: Properties{"email": "[email protected]"},
|
||||
},
|
||||
expectVal: true,
|
||||
},
|
||||
{
|
||||
name: "no match when both person and group fail",
|
||||
flagJSON: mixedTargetingFlagJSON,
|
||||
flagKey: "mixed-flag",
|
||||
opts: opts{
|
||||
groups: Groups{"company": "acme"},
|
||||
groupProperties: map[string]Properties{"company": {"plan": "free"}},
|
||||
personProperties: Properties{"email": "[email protected]"},
|
||||
},
|
||||
expectVal: false,
|
||||
},
|
||||
{
|
||||
name: "only group conditions, no groups passed: returns false without server fallback",
|
||||
flagJSON: onlyGroupConditionFlagJSON,
|
||||
flagKey: "only-group-flag",
|
||||
opts: opts{},
|
||||
expectVal: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var decideCalls atomic.Int32
|
||||
server := newMixedTargetingServer(t, tc.flagJSON, &decideCalls)
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewWithConfig("test-api-key", Config{
|
||||
PersonalApiKey: "test-personal-key",
|
||||
Endpoint: server.URL,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer client.Close()
|
||||
|
||||
result, err := client.GetFeatureFlag(FeatureFlagPayload{
|
||||
Key: tc.flagKey,
|
||||
DistinctId: "test-distinct-id",
|
||||
Groups: tc.opts.groups,
|
||||
PersonProperties: tc.opts.personProperties,
|
||||
GroupProperties: tc.opts.groupProperties,
|
||||
OnlyEvaluateLocally: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expectVal, result)
|
||||
require.Equal(t, int32(0), decideCalls.Load(), "expected no fallback to /flags decide endpoint")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Verifies the rollout for a group condition under a mixed flag hashes on the
|
||||
// group key, not the distinct_id — calling with the group passed must resolve
|
||||
// locally without falling back to the decide endpoint.
|
||||
func TestMixedTargetingRolloutBucketing(t *testing.T) {
|
||||
flagJSON := `{
|
||||
"flags": [{
|
||||
"id": 1,
|
||||
"key": "rollout-flag",
|
||||
"active": true,
|
||||
"filters": {
|
||||
"aggregation_group_type_index": null,
|
||||
"groups": [
|
||||
{
|
||||
"aggregation_group_type_index": 0,
|
||||
"properties": [],
|
||||
"rollout_percentage": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
}],
|
||||
"group_type_mapping": {"0": "company"},
|
||||
"cohorts": {}
|
||||
}`
|
||||
var decideCalls atomic.Int32
|
||||
server := newMixedTargetingServer(t, flagJSON, &decideCalls)
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewWithConfig("test-api-key", Config{
|
||||
PersonalApiKey: "test-personal-key",
|
||||
Endpoint: server.URL,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer client.Close()
|
||||
|
||||
result, err := client.GetFeatureFlag(FeatureFlagPayload{
|
||||
Key: "rollout-flag",
|
||||
DistinctId: "any-distinct-id",
|
||||
Groups: Groups{"company": "acme"},
|
||||
GroupProperties: map[string]Properties{"company": {}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, true, result)
|
||||
require.Equal(t, int32(0), decideCalls.Load(), "expected no fallback to /flags decide endpoint")
|
||||
}
|
||||
+1048
-196
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@ import (
|
||||
func newTestPoller(t *testing.T, serverURL string) *FeatureFlagsPoller {
|
||||
t.Helper()
|
||||
|
||||
localEvalURL, err := url.Parse(serverURL + "/api/feature_flag/local_evaluation")
|
||||
localEvalURL, err := url.Parse(serverURL + "/flags/definitions")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse URL: %v", err)
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func newTestPoller(t *testing.T, serverURL string) *FeatureFlagsPoller {
|
||||
func TestETagSupportForLocalEvaluation(t *testing.T) {
|
||||
t.Run("stores ETag from initial response", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Header().Set("ETag", `"abc123"`)
|
||||
w.Write([]byte(`{
|
||||
"flags": [{"key": "test-flag", "active": true, "filters": {"groups": []}}],
|
||||
@@ -63,7 +63,7 @@ func TestETagSupportForLocalEvaluation(t *testing.T) {
|
||||
var receivedIfNoneMatch string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
requestCount++
|
||||
receivedIfNoneMatch = r.Header.Get("If-None-Match")
|
||||
|
||||
@@ -105,7 +105,7 @@ func TestETagSupportForLocalEvaluation(t *testing.T) {
|
||||
var requestCount int
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
requestCount++
|
||||
|
||||
if requestCount == 1 {
|
||||
@@ -147,7 +147,7 @@ func TestETagSupportForLocalEvaluation(t *testing.T) {
|
||||
var requestCount int
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
requestCount++
|
||||
|
||||
if requestCount == 1 {
|
||||
@@ -206,7 +206,7 @@ func TestETagSupportForLocalEvaluation(t *testing.T) {
|
||||
var requestCount int
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
requestCount++
|
||||
|
||||
if requestCount == 1 {
|
||||
@@ -260,7 +260,7 @@ func TestETagSupportForLocalEvaluation(t *testing.T) {
|
||||
var requestCount int
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
requestCount++
|
||||
|
||||
if requestCount == 1 {
|
||||
@@ -315,7 +315,7 @@ func TestETagSupportForLocalEvaluation(t *testing.T) {
|
||||
var requestCount int
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
requestCount++
|
||||
|
||||
if requestCount == 1 {
|
||||
@@ -373,7 +373,7 @@ func TestETagSupportForLocalEvaluation(t *testing.T) {
|
||||
var firstRequestIfNoneMatch string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
firstRequestIfNoneMatch = r.Header.Get("If-None-Match")
|
||||
w.Header().Set("ETag", `"abc123"`)
|
||||
w.Write([]byte(`{
|
||||
|
||||
@@ -6,17 +6,20 @@ import (
|
||||
)
|
||||
|
||||
func TestCalculateHash(t *testing.T) {
|
||||
// calculateHash(key, distinctId, salt) computes SHA1(key + "." + distinctId + salt)
|
||||
// The "." is appended internally.
|
||||
for _, tt := range []struct {
|
||||
key string
|
||||
ident string
|
||||
want float64
|
||||
}{
|
||||
{"some_distinct_id", 0.7270002403585725},
|
||||
{"test-identifier", 0.4493881716040236},
|
||||
{"example_id", 0.9402003475831224},
|
||||
{"example_id2", 0.6292740389966519},
|
||||
{"holdout-", "some_distinct_id", 0.0866397292395582},
|
||||
{"holdout-", "test-identifier", 0.7496340887209227},
|
||||
{"holdout-", "example_id", 0.8691395133214396},
|
||||
{"holdout-", "example_id2", 0.8442736553863017},
|
||||
} {
|
||||
t.Run(tt.ident, func(t *testing.T) {
|
||||
got := calculateHash("holdout-", tt.ident, "")
|
||||
got := calculateHash(tt.key, tt.ident, "")
|
||||
if math.Abs(got-tt.want) > 0.000001 {
|
||||
t.Logf("got: %.16f, want: %f", got, tt.want)
|
||||
t.Fail()
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
"$geoip_disable": true,
|
||||
"$lib": "insights-go",
|
||||
"$lib_version": "1.0.0",
|
||||
"$os": "IGNORED",
|
||||
"$os_distro": "IGNORED",
|
||||
"$os_version": "IGNORED",
|
||||
"$go_version": "IGNORED",
|
||||
"alias": "A",
|
||||
"distinct_id": "B"
|
||||
},
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
"$geoip_disable": true,
|
||||
"$lib": "insights-go",
|
||||
"$lib_version": "1.0.0",
|
||||
"$os": "IGNORED",
|
||||
"$os_distro": "IGNORED",
|
||||
"$os_version": "IGNORED",
|
||||
"$go_version": "IGNORED",
|
||||
"distinct_id": "my-user-id"
|
||||
},
|
||||
"timestamp": "2025-08-11T20:43:37Z",
|
||||
|
||||
@@ -12,42 +12,65 @@ import (
|
||||
json "github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
// FlagsRequestData is the wire-format request body sent to the /flags endpoint.
|
||||
type FlagsRequestData struct {
|
||||
ApiKey string `json:"api_key"`
|
||||
DistinctId string `json:"distinct_id"`
|
||||
DeviceId *string `json:"device_id,omitempty"`
|
||||
Groups Groups `json:"groups"`
|
||||
PersonProperties Properties `json:"person_properties"`
|
||||
GroupProperties map[string]Properties `json:"group_properties"`
|
||||
DisableGeoIP bool `json:"geoip_disable,omitempty"`
|
||||
// ApiKey is the PostHog project API key.
|
||||
ApiKey string `json:"api_key"`
|
||||
// DistinctId is the user distinct ID to evaluate flags for.
|
||||
DistinctId string `json:"distinct_id"`
|
||||
// DeviceId optionally identifies the device for device-aware flag evaluation.
|
||||
DeviceId *string `json:"device_id,omitempty"`
|
||||
// Groups contains group identifiers for group-targeted flags.
|
||||
Groups Groups `json:"groups"`
|
||||
// PersonProperties overrides person properties for this evaluation.
|
||||
PersonProperties Properties `json:"person_properties"`
|
||||
// GroupProperties overrides group properties for this evaluation, keyed by group type.
|
||||
GroupProperties map[string]Properties `json:"group_properties"`
|
||||
// DisableGeoIP disables GeoIP enrichment for this flags request.
|
||||
DisableGeoIP bool `json:"geoip_disable,omitempty"`
|
||||
// FlagKeysToEvaluate asks the server to evaluate only these flag keys when non-empty.
|
||||
FlagKeysToEvaluate []string `json:"flag_keys_to_evaluate,omitempty"`
|
||||
}
|
||||
|
||||
// FlagDetail represents a feature flag in v4 format
|
||||
// FlagDetail represents one evaluated feature flag in the v4 /flags response format.
|
||||
type FlagDetail struct {
|
||||
Key string `json:"key"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Variant *string `json:"variant"`
|
||||
Reason *FlagReason `json:"reason"`
|
||||
// Key is the feature flag key.
|
||||
Key string `json:"key"`
|
||||
// Enabled reports whether the flag is enabled for the evaluated user.
|
||||
Enabled bool `json:"enabled"`
|
||||
// Variant is the matched variant key for multivariate flags, or nil for boolean flags.
|
||||
Variant *string `json:"variant"`
|
||||
// Reason explains why the flag matched or did not match, when provided by the API.
|
||||
Reason *FlagReason `json:"reason"`
|
||||
// Metadata contains IDs, version, payload, and optional flag description.
|
||||
Metadata FlagMetadata `json:"metadata"`
|
||||
Failed *bool `json:"failed,omitempty"`
|
||||
// Failed is true when the API could not evaluate this flag due to a transient failure.
|
||||
Failed *bool `json:"failed,omitempty"`
|
||||
}
|
||||
|
||||
// FlagReason represents why a flag was enabled/disabled
|
||||
// FlagReason represents why a flag was enabled or disabled.
|
||||
type FlagReason struct {
|
||||
Code string `json:"code"`
|
||||
Description string `json:"description"`
|
||||
ConditionIndex *int `json:"condition_index"`
|
||||
// Code is the machine-readable reason code returned by the API.
|
||||
Code string `json:"code"`
|
||||
// Description is the human-readable reason returned by the API.
|
||||
Description string `json:"description"`
|
||||
// ConditionIndex is the matched condition index, when available.
|
||||
ConditionIndex *int `json:"condition_index"`
|
||||
}
|
||||
|
||||
// FlagMetadata contains additional information about a flag
|
||||
// FlagMetadata contains additional information about a flag evaluation.
|
||||
type FlagMetadata struct {
|
||||
ID int `json:"id"`
|
||||
Version int `json:"version"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
// ID is the numeric feature flag ID in PostHog.
|
||||
ID int `json:"id"`
|
||||
// Version is the feature flag version evaluated by the API.
|
||||
Version int `json:"version"`
|
||||
// Payload is the raw JSON payload associated with the matched flag value.
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
// Description is the feature flag description, when returned by the API.
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// GetValue returns the variant if it exists, otherwise returns the enabled status
|
||||
// GetValue returns the variant string when Variant is set; otherwise it returns Enabled.
|
||||
func (f FlagDetail) GetValue() interface{} {
|
||||
if f.Variant != nil {
|
||||
return *f.Variant
|
||||
@@ -55,7 +78,9 @@ func (f FlagDetail) GetValue() interface{} {
|
||||
return f.Enabled
|
||||
}
|
||||
|
||||
// NewFlagDetail creates a new FlagDetail from a key, value, and optional payload
|
||||
// NewFlagDetail creates a FlagDetail from a flag key, a bool or string value,
|
||||
// and an optional raw JSON payload. String values are treated as enabled variants;
|
||||
// bool values are treated as boolean flag results.
|
||||
func NewFlagDetail(key string, value interface{}, payload json.RawMessage) FlagDetail {
|
||||
var variant *string
|
||||
var enabled bool
|
||||
@@ -81,25 +106,31 @@ func NewFlagDetail(key string, value interface{}, payload json.RawMessage) FlagD
|
||||
}
|
||||
}
|
||||
|
||||
// FlagsResponse represents the response from the flags endpoint v1 or v2.
|
||||
// It is a normalized super set of the v1 and v2 formats.
|
||||
// FlagsResponse represents a normalized /flags response.
|
||||
// It accepts both legacy v3 and current v4 response shapes.
|
||||
type FlagsResponse struct {
|
||||
// CommonResponseFields contains response metadata shared across formats.
|
||||
CommonResponseFields
|
||||
|
||||
// v4 flags format
|
||||
// Flags contains v4-format flag details keyed by flag key.
|
||||
Flags map[string]FlagDetail `json:"flags,omitempty"`
|
||||
|
||||
// v3 legacy fields
|
||||
FeatureFlags map[string]interface{} `json:"featureFlags"`
|
||||
// FeatureFlags contains legacy v3 flag values keyed by flag key.
|
||||
FeatureFlags map[string]interface{} `json:"featureFlags"`
|
||||
// FeatureFlagPayloads contains legacy v3 raw payloads keyed by flag key.
|
||||
FeatureFlagPayloads map[string]json.RawMessage `json:"featureFlagPayloads"`
|
||||
}
|
||||
|
||||
// CommonResponseFields contains fields common to all flags response versions
|
||||
// CommonResponseFields contains fields common to all flags response versions.
|
||||
type CommonResponseFields struct {
|
||||
QuotaLimited []string `json:"quota_limited"`
|
||||
RequestId string `json:"requestId"`
|
||||
EvaluatedAt *int64 `json:"evaluatedAt"`
|
||||
ErrorsWhileComputingFlags bool `json:"errorsWhileComputingFlags"`
|
||||
// QuotaLimited lists quota-limited resources returned by the API.
|
||||
QuotaLimited []string `json:"quota_limited"`
|
||||
// RequestId is the API request identifier for tracing flag evaluation.
|
||||
RequestId string `json:"requestId"`
|
||||
// EvaluatedAt is the server evaluation timestamp, when returned.
|
||||
EvaluatedAt *int64 `json:"evaluatedAt"`
|
||||
// ErrorsWhileComputingFlags reports whether the server had errors computing any flags.
|
||||
ErrorsWhileComputingFlags bool `json:"errorsWhileComputingFlags"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom unmarshaling to handle both v3 and v4 formats
|
||||
@@ -158,7 +189,7 @@ func (r *FlagsResponse) UnmarshalJSON(data []byte) error {
|
||||
// decider defines the interface for making flags requests
|
||||
type decider interface {
|
||||
makeFlagsRequest(distinctId string, deviceId *string, groups Groups, personProperties Properties,
|
||||
groupProperties map[string]Properties, disableGeoIP bool) (*FlagsResponse, error)
|
||||
groupProperties map[string]Properties, disableGeoIP bool, flagKeys []string) (*FlagsResponse, error)
|
||||
}
|
||||
|
||||
// flagsClient implements the decider interface
|
||||
@@ -193,15 +224,26 @@ func newFlagsClient(apiKey string, endpoint string, httpClient http.Client,
|
||||
// makeFlagsRequest makes a request to the flags endpoint and deserializes the response
|
||||
// into a FlagsResponse struct.
|
||||
func (d *flagsClient) makeFlagsRequest(distinctId string, deviceId *string, groups Groups, personProperties Properties,
|
||||
groupProperties map[string]Properties, disableGeoIP bool) (*FlagsResponse, error) {
|
||||
groupProperties map[string]Properties, disableGeoIP bool, flagKeys []string) (*FlagsResponse, error) {
|
||||
// Ensure non-nil maps for JSON marshaling (nil marshals as "null", server expects "{}")
|
||||
if groups == nil {
|
||||
groups = Groups{}
|
||||
}
|
||||
if personProperties == nil {
|
||||
personProperties = Properties{}
|
||||
}
|
||||
if groupProperties == nil {
|
||||
groupProperties = map[string]Properties{}
|
||||
}
|
||||
requestData := FlagsRequestData{
|
||||
ApiKey: d.apiKey,
|
||||
DistinctId: distinctId,
|
||||
DeviceId: deviceId,
|
||||
Groups: groups,
|
||||
PersonProperties: personProperties,
|
||||
GroupProperties: groupProperties,
|
||||
DisableGeoIP: disableGeoIP,
|
||||
ApiKey: d.apiKey,
|
||||
DistinctId: distinctId,
|
||||
DeviceId: deviceId,
|
||||
Groups: groups,
|
||||
PersonProperties: personProperties,
|
||||
GroupProperties: groupProperties,
|
||||
DisableGeoIP: disableGeoIP,
|
||||
FlagKeysToEvaluate: flagKeys,
|
||||
}
|
||||
|
||||
requestDataBytes, err := json.Marshal(requestData)
|
||||
@@ -254,14 +296,84 @@ func (d *flagsClient) makeFlagsRequest(distinctId string, deviceId *string, grou
|
||||
// If the raw message is a JSON string, it returns the unquoted string.
|
||||
// Otherwise, it returns the JSON representation.
|
||||
func rawMessageToString(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
n := len(raw)
|
||||
if n == 0 {
|
||||
return ""
|
||||
}
|
||||
// Try to unmarshal as a string first
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return s
|
||||
// Fast path for JSON strings: handle unquoting without json.Unmarshal
|
||||
if n >= 2 && raw[0] == '"' && raw[n-1] == '"' {
|
||||
inner := raw[1 : n-1]
|
||||
// Check for escape sequences
|
||||
hasEscape := false
|
||||
for _, b := range inner {
|
||||
if b == '\\' {
|
||||
hasEscape = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasEscape {
|
||||
// No escapes — direct conversion
|
||||
return string(inner)
|
||||
}
|
||||
// Handle common JSON escape sequences without json.Unmarshal.
|
||||
// This avoids 1-2 heap allocations from the JSON decoder.
|
||||
return unquoteJSONString(inner)
|
||||
}
|
||||
// Return raw JSON for objects, arrays, numbers, booleans
|
||||
// Handle JSON null — return empty string (matches json.Unmarshal behavior for null → string)
|
||||
if n == 4 && raw[0] == 'n' && raw[1] == 'u' && raw[2] == 'l' && raw[3] == 'l' {
|
||||
return ""
|
||||
}
|
||||
// Non-string types (objects, arrays, numbers, booleans): return raw JSON representation
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// unquoteJSONString processes JSON escape sequences in a byte slice.
|
||||
// Handles: \\, \", \/, \n, \r, \t, \b, \f. Falls back to json.Unmarshal for \uXXXX.
|
||||
func unquoteJSONString(data []byte) string {
|
||||
// Check if we need json.Unmarshal for unicode escapes
|
||||
for i := 0; i < len(data)-1; i++ {
|
||||
if data[i] == '\\' && data[i+1] == 'u' {
|
||||
// Unicode escape — fall back to json.Unmarshal
|
||||
raw := make([]byte, len(data)+2)
|
||||
raw[0] = '"'
|
||||
copy(raw[1:], data)
|
||||
raw[len(raw)-1] = '"'
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return s
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
}
|
||||
|
||||
// Process simple escape sequences in a single pass
|
||||
buf := make([]byte, 0, len(data))
|
||||
for i := 0; i < len(data); i++ {
|
||||
if data[i] == '\\' && i+1 < len(data) {
|
||||
i++
|
||||
switch data[i] {
|
||||
case '"':
|
||||
buf = append(buf, '"')
|
||||
case '\\':
|
||||
buf = append(buf, '\\')
|
||||
case '/':
|
||||
buf = append(buf, '/')
|
||||
case 'n':
|
||||
buf = append(buf, '\n')
|
||||
case 'r':
|
||||
buf = append(buf, '\r')
|
||||
case 't':
|
||||
buf = append(buf, '\t')
|
||||
case 'b':
|
||||
buf = append(buf, '\b')
|
||||
case 'f':
|
||||
buf = append(buf, '\f')
|
||||
default:
|
||||
buf = append(buf, '\\', data[i])
|
||||
}
|
||||
} else {
|
||||
buf = append(buf, data[i])
|
||||
}
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ require (
|
||||
github.com/orian/flakyhttp v0.1.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/urfave/cli v1.22.17
|
||||
golang.org/x/sys v0.21.0
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
@@ -30,6 +30,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ=
|
||||
github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiEhNVo=
|
||||
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
|
||||
+33
-12
@@ -5,15 +5,25 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// GroupIdentify represents a group analytics call that sets properties on a group.
|
||||
// Enqueue validates Type and Key, fills Uuid, Timestamp, and DisableGeoIP, then
|
||||
// sends the message as a $groupidentify event.
|
||||
type GroupIdentify struct {
|
||||
// Type is the group type, such as "company" or "organization".
|
||||
Type string
|
||||
Key string
|
||||
// Uuid is optional. If not provided, a random UUID will be generated.
|
||||
// Key is the group key or ID within the group type.
|
||||
Key string
|
||||
// Uuid is an optional event UUID. If empty, Enqueue generates a random UUID.
|
||||
Uuid string
|
||||
|
||||
DistinctId string
|
||||
Timestamp time.Time
|
||||
Properties Properties
|
||||
// DistinctId is accepted for compatibility but the wire payload uses a generated group distinct ID.
|
||||
DistinctId string
|
||||
// Timestamp is the event timestamp. If zero, Enqueue uses the current time.
|
||||
Timestamp time.Time
|
||||
// Properties are sent as the $group_set properties for the group.
|
||||
Properties Properties
|
||||
// DisableGeoIP controls whether this group-identify event disables GeoIP lookup.
|
||||
// Enqueue overwrites it from Config.GetDisableGeoIP.
|
||||
DisableGeoIP bool
|
||||
}
|
||||
|
||||
@@ -21,6 +31,7 @@ func (msg GroupIdentify) internal() {
|
||||
panic(unimplementedError)
|
||||
}
|
||||
|
||||
// Validate checks that the group identify message has Type and Key set.
|
||||
func (msg GroupIdentify) Validate() error {
|
||||
if len(msg.Type) == 0 {
|
||||
return FieldError{
|
||||
@@ -41,24 +52,34 @@ func (msg GroupIdentify) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GroupIdentifyInApi is the wire-format payload produced from a GroupIdentify message.
|
||||
type GroupIdentifyInApi struct {
|
||||
Uuid string `json:"uuid"`
|
||||
Library string `json:"library"`
|
||||
LibraryVersion string `json:"library_version"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
// Uuid is the event UUID sent to the batch API.
|
||||
Uuid string `json:"uuid"`
|
||||
// Library is the SDK name sent to the batch API.
|
||||
Library string `json:"library"`
|
||||
// LibraryVersion is the SDK version sent to the batch API.
|
||||
LibraryVersion string `json:"library_version"`
|
||||
// Timestamp is the event timestamp sent to the batch API.
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
|
||||
Event string `json:"event"`
|
||||
DistinctId string `json:"distinct_id"`
|
||||
// Event is always $groupidentify for GroupIdentify messages.
|
||||
Event string `json:"event"`
|
||||
// DistinctId is the generated group distinct ID sent to PostHog.
|
||||
DistinctId string `json:"distinct_id"`
|
||||
// Properties contains SDK metadata, group identifiers, and $group_set properties.
|
||||
Properties Properties `json:"properties"`
|
||||
}
|
||||
|
||||
// APIfy converts a GroupIdentify message into the PostHog batch API representation.
|
||||
func (msg GroupIdentify) APIfy() APIMessage {
|
||||
myProperties := Properties{}.
|
||||
Set("$lib", SDKName).
|
||||
Set("$lib_version", getVersion()).
|
||||
Set("$group_type", msg.Type).
|
||||
Set("$group_key", msg.Key).
|
||||
Set("$group_set", msg.Properties)
|
||||
Set("$group_set", msg.Properties).
|
||||
Merge(getSystemContext().ToProperties())
|
||||
|
||||
if msg.DisableGeoIP {
|
||||
myProperties.Set(propertyGeoipDisable, true)
|
||||
|
||||
@@ -6,10 +6,12 @@ package insights
|
||||
// common fields.
|
||||
type Groups map[string]interface{}
|
||||
|
||||
// NewGroups creates an empty Groups map for fluent construction.
|
||||
func NewGroups() Groups {
|
||||
return make(Groups, 10)
|
||||
}
|
||||
|
||||
// Set assigns a group type to a group key or ID and returns the receiver.
|
||||
func (p Groups) Set(name string, value interface{}) Groups {
|
||||
p[name] = value
|
||||
return p
|
||||
|
||||
+38
-16
@@ -6,17 +6,23 @@ import (
|
||||
|
||||
var _ Message = (*Identify)(nil)
|
||||
|
||||
// This type represents object sent in an identify call
|
||||
// Identify represents an identify call that sets person properties for a user.
|
||||
// Enqueue validates that DistinctId is set, fills Type, Uuid, Timestamp, and
|
||||
// DisableGeoIP, then sends the message as a $identify event.
|
||||
type Identify struct {
|
||||
// This field is exported for serialization purposes and shouldn't be set by
|
||||
// the application, its value is always overwritten by the library.
|
||||
// Type is reserved for SDK serialization and is overwritten by Enqueue.
|
||||
Type string
|
||||
// Uuid is optional. If not provided, a random UUID will be generated.
|
||||
// Uuid is an optional event UUID. If empty, Enqueue generates a random UUID.
|
||||
Uuid string
|
||||
|
||||
DistinctId string
|
||||
Timestamp time.Time
|
||||
Properties Properties
|
||||
// DistinctId is the user distinct ID whose person profile should be updated.
|
||||
DistinctId string
|
||||
// Timestamp is the event timestamp. If zero, Enqueue uses the current time.
|
||||
Timestamp time.Time
|
||||
// Properties are sent as the $set person properties for DistinctId.
|
||||
Properties Properties
|
||||
// DisableGeoIP controls whether this identify event disables GeoIP lookup.
|
||||
// Enqueue overwrites it from Config.GetDisableGeoIP.
|
||||
DisableGeoIP bool
|
||||
}
|
||||
|
||||
@@ -24,6 +30,7 @@ func (msg Identify) internal() {
|
||||
panic(unimplementedError)
|
||||
}
|
||||
|
||||
// Validate checks that the identify message has a DistinctId.
|
||||
func (msg Identify) Validate() error {
|
||||
if len(msg.DistinctId) == 0 {
|
||||
return FieldError{
|
||||
@@ -36,21 +43,36 @@ func (msg Identify) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// IdentifyInApi is the wire-format payload produced from an Identify message.
|
||||
type IdentifyInApi struct {
|
||||
Type string `json:"type"`
|
||||
Uuid string `json:"uuid"`
|
||||
Library string `json:"library"`
|
||||
LibraryVersion string `json:"library_version"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
// Type is the message type sent to the batch API.
|
||||
Type string `json:"type"`
|
||||
// Uuid is the event UUID sent to the batch API.
|
||||
Uuid string `json:"uuid"`
|
||||
// Library is the SDK name sent to the batch API.
|
||||
Library string `json:"library"`
|
||||
// LibraryVersion is the SDK version sent to the batch API.
|
||||
LibraryVersion string `json:"library_version"`
|
||||
// Timestamp is the event timestamp sent to the batch API.
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
|
||||
Event string `json:"event"`
|
||||
DistinctId string `json:"distinct_id"`
|
||||
// Event is always $identify for Identify messages.
|
||||
Event string `json:"event"`
|
||||
// DistinctId is the user distinct ID sent to PostHog.
|
||||
DistinctId string `json:"distinct_id"`
|
||||
// Properties contains SDK metadata and system context.
|
||||
Properties Properties `json:"properties"`
|
||||
Set Properties `json:"$set"`
|
||||
// Set contains the person properties to update.
|
||||
Set Properties `json:"$set"`
|
||||
}
|
||||
|
||||
// APIfy converts an Identify message into the PostHog batch API representation.
|
||||
func (msg Identify) APIfy() APIMessage {
|
||||
myProperties := Properties{}.Set("$lib", SDKName).Set("$lib_version", getVersion())
|
||||
myProperties := Properties{}.
|
||||
Set("$lib", SDKName).
|
||||
Set("$lib_version", getVersion()).
|
||||
Merge(getSystemContext().ToProperties())
|
||||
|
||||
if msg.DisableGeoIP {
|
||||
myProperties.Set(propertyGeoipDisable, true)
|
||||
}
|
||||
|
||||
+514
-81
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
|
||||
const (
|
||||
unimplementedError = "not implemented"
|
||||
// CACHE_DEFAULT_SIZE is the number of feature flag calls tracked for local deduplication.
|
||||
CACHE_DEFAULT_SIZE = 300_000
|
||||
|
||||
propertyGeoipDisable = "$geoip_disable"
|
||||
@@ -33,6 +35,7 @@ const (
|
||||
DefaultIdleConnsPerHost = 10
|
||||
)
|
||||
|
||||
// EnqueueClient is the minimal interface implemented by clients that can queue PostHog messages.
|
||||
type EnqueueClient interface {
|
||||
// Enqueue queues a message to be sent by the client when the conditions for a batch
|
||||
// upload are met.
|
||||
@@ -45,9 +48,8 @@ type EnqueueClient interface {
|
||||
// ...
|
||||
// client.Close()
|
||||
//
|
||||
// The method returns an error if the message queue could not be queued, which
|
||||
// happens if the client was already closed at the time the method was
|
||||
// called or if the message was malformed.
|
||||
// Enqueue returns an error if the message could not be queued, which happens
|
||||
// when the client is closed or the message is invalid.
|
||||
Enqueue(Message) error
|
||||
}
|
||||
|
||||
@@ -55,40 +57,65 @@ type EnqueueClient interface {
|
||||
// Values that satisfy this interface are returned by the client constructors
|
||||
// provided by the package and provide a way to send messages via the HTTP API.
|
||||
type Client interface {
|
||||
io.Closer
|
||||
EnqueueClient
|
||||
|
||||
// IsFeatureEnabled returns if a feature flag is on for a given user based on their distinct ID
|
||||
// Close gracefully shuts down the client and flushes pending messages.
|
||||
// If Config.ShutdownTimeout is positive, Close uses it as the shutdown deadline;
|
||||
// otherwise it waits indefinitely. Repeated calls return ErrClosed.
|
||||
Close() error
|
||||
|
||||
// IsFeatureEnabled evaluates one feature flag for a user and returns the same
|
||||
// value as GetFeatureFlag: a variant string for multivariate flags or a bool
|
||||
// for boolean flags. The FeatureFlagPayload parameter supplies the flag key,
|
||||
// distinct ID, optional groups/properties, and evaluation options.
|
||||
// Deprecated: Prefer EvaluateFlags for new code.
|
||||
IsFeatureEnabled(FeatureFlagPayload) (interface{}, error)
|
||||
|
||||
// GetFeatureFlag returns variant value if multivariant flag or otherwise a boolean indicating
|
||||
// if the given flag is on or off for the user
|
||||
// GetFeatureFlag evaluates one feature flag for a user. It returns a variant
|
||||
// string for multivariate flags, true or false for boolean flags, false with
|
||||
// nil error when the flag is missing/disabled, and an error for evaluation failures.
|
||||
// Deprecated: Prefer EvaluateFlags for new code.
|
||||
GetFeatureFlag(FeatureFlagPayload) (interface{}, error)
|
||||
|
||||
// GetFeatureFlagResult returns the flag value and payload together.
|
||||
// GetFeatureFlagResult evaluates one feature flag and returns its value and payload together.
|
||||
// Use this instead of calling GetFeatureFlag and GetFeatureFlagPayload separately.
|
||||
// Returns an error if the flag cannot be evaluated (e.g., flag missing or cannot be computed
|
||||
// when using OnlyEvaluateLocally).
|
||||
// It returns ErrFlagNotFound when the flag cannot be found or evaluated and
|
||||
// ErrNoPersonalAPIKey when OnlyEvaluateLocally is true without PersonalApiKey.
|
||||
GetFeatureFlagResult(FeatureFlagPayload) (*FeatureFlagResult, error)
|
||||
|
||||
// GetFeatureFlagPayload returns feature flag's payload value matching key for user (supports multivariate flags).
|
||||
// Deprecated: Use GetFeatureFlagResult instead, which returns both
|
||||
// the flag value and payload while properly tracking feature flag usage.
|
||||
// GetFeatureFlagPayload returns the payload for the matching flag value, or an
|
||||
// empty string when no payload is configured or the flag is missing/disabled.
|
||||
// Deprecated: Use GetFeatureFlagResult instead, which returns both the flag value
|
||||
// and payload while properly tracking feature flag usage.
|
||||
GetFeatureFlagPayload(FeatureFlagPayload) (string, error)
|
||||
|
||||
// GetRemoteConfigPayload returns decrypted feature flag payload value for remote config flags.
|
||||
// GetRemoteConfigPayload returns the decrypted payload for a remote config flag key.
|
||||
// It requires Config.PersonalApiKey and returns ErrNoPersonalAPIKey when missing.
|
||||
GetRemoteConfigPayload(string) (string, error)
|
||||
|
||||
// GetAllFlags returns all flags for a user
|
||||
// GetAllFlags evaluates all flags for a user. Returned values are booleans for
|
||||
// boolean flags and strings for multivariate variants. It returns ErrNoPersonalAPIKey
|
||||
// when OnlyEvaluateLocally is true without PersonalApiKey.
|
||||
GetAllFlags(FeatureFlagPayloadNoKey) (map[string]interface{}, error)
|
||||
|
||||
// ReloadFeatureFlags forces a reload of feature flags
|
||||
// NB: This is only available when using a PersonalApiKey
|
||||
ReloadFeatureFlags() error
|
||||
// EvaluateFlags returns a snapshot of feature-flag evaluations for the
|
||||
// given distinct_id using at most one /flags request. Returns ErrNoDistinctID
|
||||
// if DistinctId is empty. Pass the returned snapshot to a Capture event via
|
||||
// Capture.Flags to attach $feature/<key> properties without another network call.
|
||||
// Calls to IsEnabled and GetFlag
|
||||
// on the snapshot fire deduped $feature_flag_called events; GetFlagPayload
|
||||
// does not.
|
||||
//
|
||||
// If the remote /flags request fails after some flags were resolved
|
||||
// locally, EvaluateFlags returns a non-nil snapshot containing the
|
||||
// locally-evaluated flags alongside the error so the caller can still
|
||||
// branch on what was resolved.
|
||||
// If OnlyEvaluateLocally is true and no PersonalApiKey is configured, returns ErrNoPersonalAPIKey.
|
||||
EvaluateFlags(EvaluateFlagsPayload) (*FeatureFlagEvaluations, error)
|
||||
|
||||
// GetFeatureFlags gets all feature flags, for testing only.
|
||||
// NB: This is only available when using a PersonalApiKey
|
||||
GetFeatureFlags() ([]FeatureFlag, error)
|
||||
// ReloadFeatureFlags forces a reload of feature flags.
|
||||
// If no PersonalApiKey is configured, returns ErrNoPersonalAPIKey.
|
||||
ReloadFeatureFlags() error
|
||||
|
||||
// CloseWithContext gracefully shuts down the client with the provided context.
|
||||
// The context can be used to control the shutdown deadline.
|
||||
@@ -165,28 +192,41 @@ type flagUser struct {
|
||||
distinctID string
|
||||
flagKey string
|
||||
deviceID string
|
||||
// canonical JSON of the groups map (keys sorted) — empty when no groups
|
||||
// were passed. Lets the same `(user, flag)` fire a separate
|
||||
// `$feature_flag_called` event for each distinct group context, so
|
||||
// group-scoped flags don't undercount exposures when a user is evaluated
|
||||
// under multiple groups in the same process.
|
||||
groupsRepr string
|
||||
}
|
||||
|
||||
// Instantiate a new client that uses the write key passed as first argument to
|
||||
// send messages to the backend.
|
||||
// The client is created with the default configuration.
|
||||
// New creates a Client with the default Config and the provided PostHog project API key.
|
||||
//
|
||||
// The apiKey parameter is trimmed before use. If it is empty, New returns a
|
||||
// no-op client whose methods return default values and ErrSDKDisabled where applicable.
|
||||
func New(apiKey string) Client {
|
||||
// Here we can ignore the error because the default config is always valid.
|
||||
c, _ := NewWithConfig(apiKey, Config{})
|
||||
return c
|
||||
}
|
||||
|
||||
// NewWithConfig instantiate a new client that uses the write key and configuration passed
|
||||
// as arguments to send messages to the backend.
|
||||
// The function will return an error if the configuration contained impossible
|
||||
// values (like a negative flush interval for example).
|
||||
// When the function returns an error the returned client will always be nil.
|
||||
// NewWithConfig creates a Client with the provided PostHog project API key and Config.
|
||||
//
|
||||
// The apiKey, Config.Endpoint, and Config.PersonalApiKey values are trimmed before use.
|
||||
// It returns a ConfigError when config contains invalid values such as negative
|
||||
// intervals or out-of-range retry settings; in that case the returned Client is nil.
|
||||
// If apiKey is empty after trimming, NewWithConfig returns a no-op client and nil error.
|
||||
func NewWithConfig(apiKey string, config Config) (cli Client, err error) {
|
||||
if err = config.Validate(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
config = makeConfig(config)
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
if len(apiKey) == 0 {
|
||||
config.Logger.Errorf("posthog apiKey is empty after trimming whitespace; %s", ErrSDKDisabled)
|
||||
return newNoopClient(config), nil
|
||||
}
|
||||
reportedCache, err := lru.New[flagUser, struct{}](CACHE_DEFAULT_SIZE)
|
||||
if err != nil && config.Logger != nil {
|
||||
config.Logger.Errorf("Error creating cache for reported flags: %v", err)
|
||||
@@ -291,16 +331,21 @@ func dereferenceMessage(msg Message) Message {
|
||||
return msg
|
||||
}
|
||||
|
||||
func (c *client) Enqueue(msg Message) (err error) {
|
||||
func (c *client) Enqueue(msg Message) error {
|
||||
return c.EnqueueWithContext(context.Background(), msg)
|
||||
}
|
||||
|
||||
func (c *client) EnqueueWithContext(ctx context.Context, msg Message) (err error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
// Fast path: check if client is closed before doing any work
|
||||
if c.closed.Load() {
|
||||
return ErrClosed
|
||||
}
|
||||
|
||||
msg = dereferenceMessage(msg)
|
||||
if err = msg.Validate(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var ts = c.now()
|
||||
|
||||
@@ -320,6 +365,9 @@ func (c *client) Enqueue(msg Message) (err error) {
|
||||
|
||||
switch m := msg.(type) {
|
||||
case Alias:
|
||||
if err = m.Validate(); err != nil {
|
||||
return
|
||||
}
|
||||
m.Type = "alias"
|
||||
m.Uuid = makeUUID(m.Uuid)
|
||||
m.Timestamp = makeTimestamp(m.Timestamp, ts)
|
||||
@@ -333,6 +381,9 @@ func (c *client) Enqueue(msg Message) (err error) {
|
||||
return
|
||||
|
||||
case Identify:
|
||||
if err = m.Validate(); err != nil {
|
||||
return
|
||||
}
|
||||
m.Type = "identify"
|
||||
m.Uuid = makeUUID(m.Uuid)
|
||||
m.Timestamp = makeTimestamp(m.Timestamp, ts)
|
||||
@@ -346,6 +397,9 @@ func (c *client) Enqueue(msg Message) (err error) {
|
||||
return
|
||||
|
||||
case GroupIdentify:
|
||||
if err = m.Validate(); err != nil {
|
||||
return
|
||||
}
|
||||
m.Uuid = makeUUID(m.Uuid)
|
||||
m.Timestamp = makeTimestamp(m.Timestamp, ts)
|
||||
m.DisableGeoIP = c.GetDisableGeoIP()
|
||||
@@ -358,11 +412,34 @@ func (c *client) Enqueue(msg Message) (err error) {
|
||||
return
|
||||
|
||||
case Capture:
|
||||
if err = validateCaptureEvent(m); err != nil {
|
||||
return
|
||||
}
|
||||
m.Type = "capture"
|
||||
m.Uuid = makeUUID(m.Uuid)
|
||||
m.Timestamp = makeTimestamp(m.Timestamp, ts)
|
||||
if m.shouldSendFeatureFlags() {
|
||||
// Add all feature variants to event
|
||||
captureContext, captureContextErr := resolveCaptureContext(ctx, m.DistinctId, m.Properties, "posthog.Capture")
|
||||
if captureContextErr != nil {
|
||||
err = captureContextErr
|
||||
return
|
||||
}
|
||||
m.DistinctId = captureContext.distinctID
|
||||
m.Properties = captureContext.properties
|
||||
if err = m.Validate(); err != nil {
|
||||
return
|
||||
}
|
||||
if m.Flags != nil {
|
||||
if m.shouldSendFeatureFlags() {
|
||||
c.Warnf("[FEATURE FLAGS] Both Flags and SendFeatureFlags were set on Capture; using Flags and ignoring SendFeatureFlags.")
|
||||
}
|
||||
// Generated flag properties go down first so user-supplied
|
||||
// Properties override them on conflict — matches the Python SDK's
|
||||
// merge order so callers can manually overwrite $feature/<key>
|
||||
// or $active_feature_flags if they need to.
|
||||
m.Properties = m.Flags.eventProperties().Merge(m.Properties)
|
||||
} else if m.shouldSendFeatureFlags() && !captureContext.generatedPersonlessDistinctID {
|
||||
// Add all feature variants to event. Skip for generated personless IDs so
|
||||
// feature flag evaluation always uses a stable caller- or context-supplied ID.
|
||||
personProperties := NewProperties()
|
||||
groupProperties := map[string]Properties{}
|
||||
opts := m.getFeatureFlagsOptions()
|
||||
@@ -403,6 +480,9 @@ func (c *client) Enqueue(msg Message) (err error) {
|
||||
m.Properties = NewProperties()
|
||||
}
|
||||
m.Properties.Merge(c.DefaultEventProperties)
|
||||
if captureContext.personlessProcessProfileGuard {
|
||||
m.Properties[propertyProcessPersonProfile] = false
|
||||
}
|
||||
data, apiMsg, serErr := prepareForSend(m)
|
||||
if serErr != nil {
|
||||
c.notifyFailure([]APIMessage{apiMsg}, serErr)
|
||||
@@ -416,6 +496,16 @@ func (c *client) Enqueue(msg Message) (err error) {
|
||||
m.Uuid = makeUUID(m.Uuid)
|
||||
m.Timestamp = makeTimestamp(m.Timestamp, ts)
|
||||
m.DisableGeoIP = c.GetDisableGeoIP()
|
||||
captureContext, captureContextErr := resolveCaptureContext(ctx, m.DistinctId, m.Properties, "posthog.Exception")
|
||||
if captureContextErr != nil {
|
||||
err = captureContextErr
|
||||
return
|
||||
}
|
||||
m.DistinctId = captureContext.distinctID
|
||||
m.Properties = captureContext.properties
|
||||
if err = m.Validate(); err != nil {
|
||||
return
|
||||
}
|
||||
data, apiMsg, serErr := prepareForSend(m)
|
||||
if serErr != nil {
|
||||
c.notifyFailure([]APIMessage{apiMsg}, serErr)
|
||||
@@ -425,6 +515,9 @@ func (c *client) Enqueue(msg Message) (err error) {
|
||||
return
|
||||
|
||||
default:
|
||||
if err = msg.Validate(); err != nil {
|
||||
return
|
||||
}
|
||||
err = fmt.Errorf("messages with custom types cannot be enqueued: %T", msg)
|
||||
return
|
||||
}
|
||||
@@ -499,45 +592,58 @@ func (c *client) getFeatureFlagResultWithContext(ctx context.Context, flagConfig
|
||||
if err := flagConfig.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.featureFlagsPoller == nil && flagConfig.OnlyEvaluateLocally {
|
||||
c.warnPersonalAPIKeyMissing("GetFeatureFlagResult")
|
||||
return nil, ErrNoPersonalAPIKey
|
||||
}
|
||||
|
||||
var flagValue interface{}
|
||||
var payload *string
|
||||
var variant *string
|
||||
var err error
|
||||
var evalResult *featureFlagEvaluationResult
|
||||
// Use stack-allocated evalResult to avoid heap pointer allocation on the hot path
|
||||
var evalResult featureFlagEvaluationResult
|
||||
// payloadStr and variantStr hold values that will be stored in the result struct
|
||||
var payloadStr string
|
||||
var variantStr string
|
||||
var hasPayload, hasVariant bool
|
||||
var locallyEvaluated bool
|
||||
|
||||
if c.featureFlagsPoller != nil {
|
||||
// get feature flag from the poller, which uses the personal api key
|
||||
// this is only available when using a PersonalApiKey
|
||||
flagValue, err = c.featureFlagsPoller.GetFeatureFlag(flagConfig)
|
||||
evalResult = &featureFlagEvaluationResult{
|
||||
Value: flagValue,
|
||||
Err: err,
|
||||
}
|
||||
if err == nil {
|
||||
payloadStr, _ := c.featureFlagsPoller.GetFeatureFlagPayload(flagConfig)
|
||||
if payloadStr != "" {
|
||||
payload = &payloadStr
|
||||
}
|
||||
// Evaluate flag once to get both value and payload (avoids double evaluation)
|
||||
combined := c.featureFlagsPoller.GetFeatureFlagWithPayload(flagConfig)
|
||||
flagValue = combined.value
|
||||
locallyEvaluated = combined.locallyEvaluated
|
||||
err = combined.err
|
||||
evalResult.Value = flagValue
|
||||
evalResult.Err = err
|
||||
if combined.payload != "" {
|
||||
payloadStr = combined.payload
|
||||
hasPayload = true
|
||||
}
|
||||
if v, ok := flagValue.(string); ok {
|
||||
variant = &v
|
||||
variantStr = v
|
||||
hasVariant = true
|
||||
}
|
||||
} else {
|
||||
// if there's no poller, get the feature flag from the flags endpoint
|
||||
c.debugf("getting feature flag from flags endpoint")
|
||||
evalResult = c.getFeatureFlagFromRemote(flagConfig.Key, flagConfig.DistinctId, flagConfig.DeviceId, flagConfig.Groups,
|
||||
locallyEvaluated = false
|
||||
remoteResult := c.getFeatureFlagFromRemote(flagConfig.Key, flagConfig.DistinctId, flagConfig.DeviceId, flagConfig.Groups,
|
||||
flagConfig.PersonProperties, flagConfig.GroupProperties)
|
||||
evalResult = *remoteResult
|
||||
flagValue = evalResult.Value
|
||||
err = evalResult.Err
|
||||
if f, ok := flagValue.(FlagDetail); ok {
|
||||
flagValue = f.GetValue()
|
||||
evalResult.Value = flagValue
|
||||
payloadStr := rawMessageToString(f.Metadata.Payload)
|
||||
if payloadStr != "" {
|
||||
payload = &payloadStr
|
||||
ps := rawMessageToString(f.Metadata.Payload)
|
||||
if ps != "" {
|
||||
payloadStr = ps
|
||||
hasPayload = true
|
||||
}
|
||||
if f.Variant != nil {
|
||||
variantStr = *f.Variant
|
||||
hasVariant = true
|
||||
}
|
||||
variant = f.Variant
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,15 +652,11 @@ func (c *client) getFeatureFlagResultWithContext(ctx context.Context, flagConfig
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
deviceID := ""
|
||||
if flagConfig.DeviceId != nil {
|
||||
deviceID = *flagConfig.DeviceId
|
||||
}
|
||||
cacheKey := flagUser{flagConfig.DistinctId, flagConfig.Key, deviceID}
|
||||
if *flagConfig.SendFeatureFlagEvents && !c.distinctIdsFeatureFlagsReported.Contains(cacheKey) {
|
||||
if *flagConfig.SendFeatureFlagEvents {
|
||||
var properties = NewProperties().
|
||||
Set("$feature_flag", flagConfig.Key).
|
||||
Set("$feature_flag_response", flagValue)
|
||||
Set("$feature_flag_response", flagValue).
|
||||
Set("locally_evaluated", locallyEvaluated)
|
||||
|
||||
if flagConfig.DeviceId != nil {
|
||||
properties.Set("$device_id", *flagConfig.DeviceId)
|
||||
@@ -581,14 +683,7 @@ func (c *client) getFeatureFlagResultWithContext(ctx context.Context, flagConfig
|
||||
properties.Set("$feature_flag_error", errorString)
|
||||
}
|
||||
|
||||
if c.Enqueue(Capture{
|
||||
DistinctId: flagConfig.DistinctId,
|
||||
Event: "$feature_flag_called",
|
||||
Properties: properties,
|
||||
Groups: flagConfig.Groups,
|
||||
}) == nil {
|
||||
c.distinctIdsFeatureFlagsReported.Add(cacheKey, struct{}{})
|
||||
}
|
||||
c.captureFlagCalledIfNeeded(flagConfig.DistinctId, flagConfig.Key, flagConfig.DeviceId, properties, flagConfig.Groups)
|
||||
}
|
||||
|
||||
if flagValue == nil {
|
||||
@@ -609,12 +704,91 @@ func (c *client) getFeatureFlagResultWithContext(ctx context.Context, flagConfig
|
||||
enabled = v != ""
|
||||
}
|
||||
|
||||
return &FeatureFlagResult{
|
||||
Key: flagConfig.Key,
|
||||
Enabled: enabled,
|
||||
RawPayload: payload,
|
||||
Variant: variant,
|
||||
}, err
|
||||
// Build result with embedded string storage so RawPayload/Variant pointers
|
||||
// reference fields within the same heap allocation (no separate string escapes).
|
||||
result := &FeatureFlagResult{
|
||||
Key: flagConfig.Key,
|
||||
Enabled: enabled,
|
||||
payloadStore: payloadStr,
|
||||
variantStore: variantStr,
|
||||
}
|
||||
if hasPayload {
|
||||
result.RawPayload = &result.payloadStore
|
||||
}
|
||||
if hasVariant {
|
||||
result.Variant = &result.variantStore
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// captureFlagCalledIfNeeded fires a $feature_flag_called event if the
|
||||
// (distinctId, key, deviceId, groups) tuple has not already been reported on
|
||||
// this client. Group context is included so group-scoped flags fire a
|
||||
// separate event for each group a user is evaluated under. The caller is
|
||||
// responsible for building the full properties dict; this helper only handles
|
||||
// dedup and enqueue. It is shared by the legacy per-flag evaluation path and
|
||||
// the FeatureFlagEvaluations snapshot path so both dedupe identically against
|
||||
// the same per-distinct_id LRU cache.
|
||||
func (c *client) captureFlagCalledIfNeeded(distinctId, key string, deviceId *string, properties Properties, groups Groups) {
|
||||
c.captureFlagCalledIfNeededWithContext(context.Background(), distinctId, key, deviceId, properties, groups)
|
||||
}
|
||||
|
||||
func (c *client) captureFlagCalledIfNeededWithContext(ctx context.Context, distinctId, key string, deviceId *string, properties Properties, groups Groups) {
|
||||
deviceIDStr := ""
|
||||
if deviceId != nil {
|
||||
deviceIDStr = *deviceId
|
||||
}
|
||||
cacheKey := flagUser{
|
||||
distinctID: distinctId,
|
||||
flagKey: key,
|
||||
deviceID: deviceIDStr,
|
||||
groupsRepr: canonicalGroupsRepr(groups),
|
||||
}
|
||||
if c.distinctIdsFeatureFlagsReported.Contains(cacheKey) {
|
||||
return
|
||||
}
|
||||
if err := c.EnqueueWithContext(ctx, Capture{
|
||||
DistinctId: distinctId,
|
||||
Event: "$feature_flag_called",
|
||||
Properties: properties,
|
||||
Groups: groups,
|
||||
}); err == nil {
|
||||
c.distinctIdsFeatureFlagsReported.Add(cacheKey, struct{}{})
|
||||
}
|
||||
}
|
||||
|
||||
// canonicalGroupsRepr returns a JSON string of the sorted key/value pairs of
|
||||
// the groups map. Two equal maps that were built with keys inserted in a
|
||||
// different order produce the same string, so they dedupe to one cache entry.
|
||||
// Empty / nil groups produce an empty string.
|
||||
func canonicalGroupsRepr(groups Groups) string {
|
||||
if len(groups) == 0 {
|
||||
return ""
|
||||
}
|
||||
keys := make([]string, 0, len(groups))
|
||||
for k := range groups {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
pairs := make([][2]interface{}, len(keys))
|
||||
for i, k := range keys {
|
||||
pairs[i] = [2]interface{}{k, groups[k]}
|
||||
}
|
||||
b, err := json.Marshal(pairs)
|
||||
if err != nil {
|
||||
// Fallback to a stable string concat — the JSON encode failure path
|
||||
// shouldn't realistically trigger for a map[string]interface{}, but
|
||||
// staying deterministic keeps the dedup behavior consistent.
|
||||
var sb strings.Builder
|
||||
for _, k := range keys {
|
||||
sb.WriteString(k)
|
||||
sb.WriteString("=")
|
||||
sb.WriteString(fmt.Sprintf("%v", groups[k]))
|
||||
sb.WriteString(";")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (c *client) GetRemoteConfigPayload(flagKey string) (string, error) {
|
||||
@@ -653,13 +827,16 @@ func (c *client) getAllFlagsWithContext(ctx context.Context, flagConfig FeatureF
|
||||
if err := flagConfig.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.featureFlagsPoller == nil && flagConfig.OnlyEvaluateLocally {
|
||||
c.warnPersonalAPIKeyMissing("GetAllFlags")
|
||||
return nil, ErrNoPersonalAPIKey
|
||||
}
|
||||
|
||||
var flagsValue map[string]interface{}
|
||||
var err error
|
||||
|
||||
if c.featureFlagsPoller != nil {
|
||||
// get feature flags from the poller, which uses the personal api key
|
||||
// this is only available when using a PersonalApiKey
|
||||
flagsValue, err = c.featureFlagsPoller.GetAllFlags(flagConfig)
|
||||
} else {
|
||||
// if there's no poller, get the feature flags from the flags endpoint
|
||||
@@ -676,6 +853,253 @@ func (c *client) getAllFlagsWithContext(ctx context.Context, flagConfig FeatureF
|
||||
return flagsValue, err
|
||||
}
|
||||
|
||||
// EvaluateFlagsPayload is the input to Client.EvaluateFlags.
|
||||
type EvaluateFlagsPayload struct {
|
||||
// DistinctId is the user distinct ID to evaluate flags for. It is required
|
||||
// unless EvaluateFlagsWithContext can read one from RequestContext.
|
||||
DistinctId string
|
||||
// DeviceId optionally provides a device_id for remote /flags requests and event deduplication.
|
||||
DeviceId *string
|
||||
// Groups supplies group identifiers for group-targeted flags.
|
||||
Groups Groups
|
||||
// PersonProperties overrides person properties used during flag evaluation.
|
||||
PersonProperties Properties
|
||||
// GroupProperties overrides group properties used during flag evaluation, keyed by group type.
|
||||
GroupProperties map[string]Properties
|
||||
// OnlyEvaluateLocally prevents fallback to remote /flags requests.
|
||||
OnlyEvaluateLocally bool
|
||||
// DisableGeoIP, when non-nil, overrides the client-level DisableGeoIP for
|
||||
// this evaluation only.
|
||||
DisableGeoIP *bool
|
||||
// FlagKeys, when non-empty, trims the network call by asking the server
|
||||
// to evaluate only the named flags (sent as flag_keys_to_evaluate).
|
||||
// This is server-side filtering; use FeatureFlagEvaluations.Only to do
|
||||
// client-side filtering of which flags are attached to events from an
|
||||
// existing snapshot.
|
||||
FlagKeys []string
|
||||
}
|
||||
|
||||
func (c *client) EvaluateFlags(payload EvaluateFlagsPayload) (*FeatureFlagEvaluations, error) {
|
||||
return c.evaluateFlagsWithContext(context.Background(), payload)
|
||||
}
|
||||
|
||||
func (c *client) EvaluateFlagsWithContext(ctx context.Context, payload EvaluateFlagsPayload) (*FeatureFlagEvaluations, error) {
|
||||
return c.evaluateFlagsWithContext(ctx, payload)
|
||||
}
|
||||
|
||||
func (c *client) evaluateFlagsWithContext(ctx context.Context, payload EvaluateFlagsPayload) (*FeatureFlagEvaluations, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if payload.DistinctId == "" {
|
||||
if requestContext, ok := RequestContextFromContext(ctx); ok {
|
||||
payload.DistinctId = requestContext.DistinctId
|
||||
}
|
||||
}
|
||||
|
||||
host := c.featureFlagEvaluationsHostWithContext(ctx)
|
||||
|
||||
if payload.DistinctId == "" {
|
||||
c.Warnf("EvaluateFlags called without a DistinctId")
|
||||
return noopFeatureFlagEvaluations, ErrNoDistinctID
|
||||
}
|
||||
|
||||
if payload.Groups == nil {
|
||||
payload.Groups = Groups{}
|
||||
}
|
||||
if payload.PersonProperties == nil {
|
||||
payload.PersonProperties = NewProperties()
|
||||
}
|
||||
if payload.GroupProperties == nil {
|
||||
payload.GroupProperties = map[string]Properties{}
|
||||
}
|
||||
|
||||
disableGeoIP := c.GetDisableGeoIP()
|
||||
if payload.DisableGeoIP != nil {
|
||||
disableGeoIP = *payload.DisableGeoIP
|
||||
}
|
||||
|
||||
records := make(map[string]evaluatedFlagRecord)
|
||||
locallyEvaluated := make(map[string]struct{})
|
||||
fallbackToRemote := true
|
||||
|
||||
if c.featureFlagsPoller != nil {
|
||||
fallbackToRemote = c.populateLocalEvaluations(records, locallyEvaluated, payload)
|
||||
} else if payload.OnlyEvaluateLocally {
|
||||
c.warnPersonalAPIKeyMissing("EvaluateFlags")
|
||||
return noopFeatureFlagEvaluations, ErrNoPersonalAPIKey
|
||||
}
|
||||
|
||||
var requestId string
|
||||
var evaluatedAt *int64
|
||||
var errorsWhileComputing bool
|
||||
var quotaLimited bool
|
||||
|
||||
// remoteErr is returned alongside the snapshot when the /flags request
|
||||
// fails, so callers still get any locally-evaluated flags collected above.
|
||||
var remoteErr error
|
||||
if fallbackToRemote && !payload.OnlyEvaluateLocally {
|
||||
flagsResponse, err := c.decider.makeFlagsRequest(
|
||||
payload.DistinctId,
|
||||
payload.DeviceId,
|
||||
payload.Groups,
|
||||
payload.PersonProperties,
|
||||
payload.GroupProperties,
|
||||
disableGeoIP,
|
||||
payload.FlagKeys,
|
||||
)
|
||||
if err != nil {
|
||||
remoteErr = err
|
||||
} else if flagsResponse != nil {
|
||||
requestId = flagsResponse.RequestId
|
||||
evaluatedAt = flagsResponse.EvaluatedAt
|
||||
errorsWhileComputing = flagsResponse.ErrorsWhileComputingFlags
|
||||
quotaLimited = c.isFeatureFlagsQuotaLimited(flagsResponse)
|
||||
if !quotaLimited {
|
||||
for key, detail := range flagsResponse.Flags {
|
||||
if _, alreadyLocal := locallyEvaluated[key]; alreadyLocal {
|
||||
continue
|
||||
}
|
||||
records[key] = recordFromFlagDetail(detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &FeatureFlagEvaluations{
|
||||
host: host,
|
||||
distinctId: payload.DistinctId,
|
||||
deviceId: payload.DeviceId,
|
||||
groups: payload.Groups,
|
||||
flags: records,
|
||||
requestId: requestId,
|
||||
evaluatedAt: evaluatedAt,
|
||||
errorsWhileComputing: errorsWhileComputing,
|
||||
quotaLimited: quotaLimited,
|
||||
accessed: map[string]struct{}{},
|
||||
}, remoteErr
|
||||
}
|
||||
|
||||
// populateLocalEvaluations fills records with locally-resolved flags. It
|
||||
// returns whether the caller should fall back to a remote /flags request to
|
||||
// fill in the rest. The local-evaluation loop here mirrors
|
||||
// FeatureFlagsPoller.GetAllFlags but stores the rich record needed to power
|
||||
// $feature_flag_called events with locally_evaluated=true.
|
||||
func (c *client) populateLocalEvaluations(records map[string]evaluatedFlagRecord, locallyEvaluated map[string]struct{}, payload EvaluateFlagsPayload) bool {
|
||||
poller := c.featureFlagsPoller
|
||||
featureFlags, err := poller.GetFeatureFlags()
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
if len(featureFlags) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
flagKeyFilter := map[string]struct{}{}
|
||||
for _, k := range payload.FlagKeys {
|
||||
flagKeyFilter[k] = struct{}{}
|
||||
}
|
||||
|
||||
cohorts := poller.getCohorts()
|
||||
fallbackToRemote := false
|
||||
const localReason = "Evaluated locally"
|
||||
|
||||
for _, storedFlag := range featureFlags {
|
||||
if len(flagKeyFilter) > 0 {
|
||||
if _, ok := flagKeyFilter[storedFlag.Key]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
value, err := poller.computeFlagLocally(
|
||||
storedFlag,
|
||||
payload.DistinctId,
|
||||
payload.DeviceId,
|
||||
payload.Groups,
|
||||
payload.PersonProperties,
|
||||
payload.GroupProperties,
|
||||
cohorts,
|
||||
)
|
||||
if err != nil {
|
||||
c.debugf("Unable to compute flag '%s' locally - %s", storedFlag.Key, err)
|
||||
fallbackToRemote = true
|
||||
continue
|
||||
}
|
||||
|
||||
record := evaluatedFlagRecord{
|
||||
Key: storedFlag.Key,
|
||||
LocallyEvaluated: true,
|
||||
Reason: ptrString(localReason),
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
record.Enabled = v
|
||||
case string:
|
||||
record.Enabled = true
|
||||
variant := v
|
||||
record.Variant = &variant
|
||||
default:
|
||||
record.Enabled = false
|
||||
}
|
||||
|
||||
if record.Enabled {
|
||||
variantKey := "true"
|
||||
if record.Variant != nil {
|
||||
variantKey = *record.Variant
|
||||
}
|
||||
if rawPayload, ok := storedFlag.Filters.Payloads[variantKey]; ok {
|
||||
if s := rawMessageToString(rawPayload); s != "" {
|
||||
payloadStr := s
|
||||
record.Payload = &payloadStr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
records[storedFlag.Key] = record
|
||||
locallyEvaluated[storedFlag.Key] = struct{}{}
|
||||
}
|
||||
|
||||
return fallbackToRemote
|
||||
}
|
||||
|
||||
// recordFromFlagDetail builds an evaluatedFlagRecord from a v4 FlagDetail.
|
||||
func recordFromFlagDetail(detail FlagDetail) evaluatedFlagRecord {
|
||||
record := evaluatedFlagRecord{
|
||||
Key: detail.Key,
|
||||
Enabled: detail.Enabled,
|
||||
Variant: detail.Variant,
|
||||
}
|
||||
if detail.Failed != nil && *detail.Failed {
|
||||
record.Enabled = false
|
||||
errStr := FeatureFlagErrorEvaluationFailed
|
||||
record.Error = &errStr
|
||||
}
|
||||
id := detail.Metadata.ID
|
||||
record.ID = &id
|
||||
version := detail.Metadata.Version
|
||||
record.Version = &version
|
||||
if detail.Reason != nil {
|
||||
reason := detail.Reason.Description
|
||||
record.Reason = &reason
|
||||
}
|
||||
if s := rawMessageToString(detail.Metadata.Payload); s != "" {
|
||||
payloadStr := s
|
||||
record.Payload = &payloadStr
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func ptrString(s string) *string { return &s }
|
||||
|
||||
// featureFlagEvaluationsHostWithContext wires the snapshot's callbacks to this client.
|
||||
func (c *client) featureFlagEvaluationsHostWithContext(ctx context.Context) featureFlagEvaluationsHost {
|
||||
return featureFlagEvaluationsHost{
|
||||
captureFlagCalledIfNeeded: func(distinctId, key string, deviceId *string, properties Properties, groups Groups) {
|
||||
c.captureFlagCalledIfNeededWithContext(ctx, distinctId, key, deviceId, properties, groups)
|
||||
},
|
||||
logger: c.Logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Close gracefully shuts down the client, flushing any pending messages.
|
||||
// If ShutdownTimeout is set to a positive duration, Close waits up to that
|
||||
// duration for in-flight requests to complete. Otherwise, it waits indefinitely.
|
||||
@@ -1097,6 +1521,10 @@ func (c *client) debugf(format string, args ...interface{}) {
|
||||
c.Logger.Debugf(format, args...)
|
||||
}
|
||||
|
||||
func (c *client) warnPersonalAPIKeyMissing(method string) {
|
||||
c.Warnf("PostHog personal_api_key is not configured; %s requires a PersonalApiKey.", method)
|
||||
}
|
||||
|
||||
func (c *client) Errorf(format string, args ...interface{}) {
|
||||
c.Logger.Errorf(format, args...)
|
||||
}
|
||||
@@ -1160,6 +1588,11 @@ func (c *client) makeRemoteConfigRequest(flagKey string) (string, error) {
|
||||
return "", fmt.Errorf("parsing URL: %v", err)
|
||||
}
|
||||
|
||||
if c.PersonalApiKey == "" {
|
||||
c.warnPersonalAPIKeyMissing("GetRemoteConfigPayload")
|
||||
return "", ErrNoPersonalAPIKey
|
||||
}
|
||||
|
||||
q := parsedURL.Query()
|
||||
q.Set("token", c.key)
|
||||
parsedURL.RawQuery = q.Encode()
|
||||
@@ -1216,7 +1649,7 @@ func (c *client) getFeatureFlagFromRemote(key string, distinctId string, deviceI
|
||||
Value: nil,
|
||||
}
|
||||
|
||||
flagsResponse, err := c.decider.makeFlagsRequest(distinctId, deviceId, groups, personProperties, groupProperties, c.GetDisableGeoIP())
|
||||
flagsResponse, err := c.decider.makeFlagsRequest(distinctId, deviceId, groups, personProperties, groupProperties, c.GetDisableGeoIP(), nil)
|
||||
|
||||
if err != nil {
|
||||
result.Err = err
|
||||
@@ -1254,13 +1687,13 @@ func (c *client) getFeatureFlagFromRemote(key string, distinctId string, deviceI
|
||||
}
|
||||
|
||||
func (c *client) getAllFeatureFlagsFromRemote(distinctId string, deviceId *string, groups Groups, personProperties Properties, groupProperties map[string]Properties) (map[string]interface{}, error) {
|
||||
flagsResponse, err := c.decider.makeFlagsRequest(distinctId, deviceId, groups, personProperties, groupProperties, c.GetDisableGeoIP())
|
||||
flagsResponse, err := c.decider.makeFlagsRequest(distinctId, deviceId, groups, personProperties, groupProperties, c.GetDisableGeoIP(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if c.isFeatureFlagsQuotaLimited(flagsResponse) {
|
||||
return map[string]interface{}{}, nil
|
||||
return emptyFlagValues, nil
|
||||
}
|
||||
|
||||
return flagsResponse.FeatureFlags, nil
|
||||
|
||||
+350
-49
@@ -19,6 +19,8 @@ import (
|
||||
"time"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -83,6 +85,209 @@ func (l testLogger) Errorf(format string, args ...interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWithConfig_LogsErrorForBlankAPIKeyAfterTrim(t *testing.T) {
|
||||
var logged string
|
||||
|
||||
client, err := NewWithConfig(" \n\t ", Config{
|
||||
Logger: testLogger{
|
||||
logf: t.Logf,
|
||||
errorf: func(format string, args ...interface{}) {
|
||||
logged = fmt.Sprintf(format, args...)
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, client)
|
||||
defer client.Close()
|
||||
|
||||
require.Contains(t, logged, "apiKey is empty after trimming whitespace")
|
||||
require.Contains(t, logged, ErrSDKDisabled.Error())
|
||||
}
|
||||
|
||||
func TestNew_BlankAPIKeyReturnsNoopClient(t *testing.T) {
|
||||
client := New(" \n\t ")
|
||||
require.NotNil(t, client)
|
||||
|
||||
_, ok := client.(*noopClient)
|
||||
require.True(t, ok)
|
||||
require.ErrorIs(t, client.Enqueue(Capture{}), ErrSDKDisabled)
|
||||
|
||||
isEnabled, err := client.IsFeatureEnabled(FeatureFlagPayload{})
|
||||
require.ErrorIs(t, err, ErrSDKDisabled)
|
||||
require.Equal(t, false, isEnabled)
|
||||
|
||||
flag, err := client.GetFeatureFlag(FeatureFlagPayload{})
|
||||
require.ErrorIs(t, err, ErrSDKDisabled)
|
||||
require.Equal(t, false, flag)
|
||||
|
||||
result, err := client.GetFeatureFlagResult(FeatureFlagPayload{})
|
||||
require.ErrorIs(t, err, ErrSDKDisabled)
|
||||
require.NotNil(t, result)
|
||||
require.False(t, result.Enabled)
|
||||
|
||||
payload, err := client.GetFeatureFlagPayload(FeatureFlagPayload{})
|
||||
require.ErrorIs(t, err, ErrSDKDisabled)
|
||||
require.Empty(t, payload)
|
||||
|
||||
allFlags, err := client.GetAllFlags(FeatureFlagPayloadNoKey{})
|
||||
require.ErrorIs(t, err, ErrSDKDisabled)
|
||||
require.Empty(t, allFlags)
|
||||
|
||||
evaluations, err := client.EvaluateFlags(EvaluateFlagsPayload{})
|
||||
require.ErrorIs(t, err, ErrSDKDisabled)
|
||||
require.NotNil(t, evaluations)
|
||||
require.Empty(t, evaluations.Keys())
|
||||
|
||||
require.ErrorIs(t, client.ReloadFeatureFlags(), ErrSDKDisabled)
|
||||
require.ErrorIs(t, client.Close(), ErrSDKDisabled)
|
||||
require.ErrorIs(t, client.Close(), ErrSDKDisabled)
|
||||
}
|
||||
|
||||
func TestNewWithConfig_BlankAPIKeyReturnsNoopClientWithoutRequests(t *testing.T) {
|
||||
var requests atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var successes atomic.Int64
|
||||
var failures atomic.Int64
|
||||
client, err := NewWithConfig(" \n\t ", Config{
|
||||
Endpoint: server.URL,
|
||||
PersonalApiKey: "personal-api-key",
|
||||
BatchSize: 1,
|
||||
Interval: time.Millisecond,
|
||||
Callback: testCallback{
|
||||
success: func(APIMessage) { successes.Add(1) },
|
||||
failure: func(APIMessage, error) { failures.Add(1) },
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, client)
|
||||
_, ok := client.(*noopClient)
|
||||
require.True(t, ok)
|
||||
|
||||
require.ErrorIs(t, client.Enqueue(Capture{DistinctId: "test-user", Event: "test-event"}), ErrSDKDisabled)
|
||||
isEnabled, err := client.IsFeatureEnabled(FeatureFlagPayload{Key: "test-flag", DistinctId: "test-user"})
|
||||
require.ErrorIs(t, err, ErrSDKDisabled)
|
||||
require.Equal(t, false, isEnabled)
|
||||
allFlags, err := client.GetAllFlags(FeatureFlagPayloadNoKey{DistinctId: "test-user"})
|
||||
require.ErrorIs(t, err, ErrSDKDisabled)
|
||||
require.Empty(t, allFlags)
|
||||
remoteConfigPayload, err := client.GetRemoteConfigPayload("test-flag")
|
||||
require.ErrorIs(t, err, ErrSDKDisabled)
|
||||
require.Empty(t, remoteConfigPayload)
|
||||
require.ErrorIs(t, client.Close(), ErrSDKDisabled)
|
||||
|
||||
require.Zero(t, requests.Load())
|
||||
require.Zero(t, successes.Load())
|
||||
require.Zero(t, failures.Load())
|
||||
}
|
||||
|
||||
func TestNewWithConfig_BlankAPIKeyWithPersonalAPIKeyReturnsNoopClient(t *testing.T) {
|
||||
var requests atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewWithConfig(" \n\t ", Config{
|
||||
Endpoint: server.URL,
|
||||
PersonalApiKey: "personal-api-key",
|
||||
BatchSize: 1,
|
||||
Interval: time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, client)
|
||||
_, ok := client.(*noopClient)
|
||||
require.True(t, ok)
|
||||
|
||||
require.ErrorIs(t, client.Enqueue(Capture{DistinctId: "test-user", Event: "test-event"}), ErrSDKDisabled)
|
||||
isEnabled, err := client.IsFeatureEnabled(FeatureFlagPayload{Key: "test-flag", DistinctId: "test-user"})
|
||||
require.ErrorIs(t, err, ErrSDKDisabled)
|
||||
require.Equal(t, false, isEnabled)
|
||||
remoteConfigPayload, err := client.GetRemoteConfigPayload("test-flag")
|
||||
require.ErrorIs(t, err, ErrSDKDisabled)
|
||||
require.Empty(t, remoteConfigPayload)
|
||||
require.ErrorIs(t, client.Close(), ErrSDKDisabled)
|
||||
|
||||
require.Zero(t, requests.Load())
|
||||
}
|
||||
|
||||
func TestNewWithConfig_TrimsWhitespaceSensitiveInputsInRequests(t *testing.T) {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
batchAPIKey string
|
||||
remoteConfigToken string
|
||||
remoteConfigAuth string
|
||||
)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/flags/definitions"):
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer test-personal-key" {
|
||||
t.Errorf("Expected Authorization header 'Bearer test-personal-key', got %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"flags": [], "group_type_mapping": {}}`))
|
||||
case r.URL.Path == "/batch/":
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to read batch body: %v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
ApiKey string `json:"api_key"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Errorf("Failed to parse batch body: %v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
batchAPIKey = payload.ApiKey
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case strings.Contains(r.URL.Path, "/remote_config"):
|
||||
mu.Lock()
|
||||
remoteConfigToken = r.URL.Query().Get("token")
|
||||
remoteConfigAuth = r.Header.Get("Authorization")
|
||||
mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`"ok"`))
|
||||
default:
|
||||
t.Errorf("Unexpected request to %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewWithConfig(" \n test-api-key\t ", Config{
|
||||
PersonalApiKey: " \ttest-personal-key\n ",
|
||||
Endpoint: " \n" + server.URL + "\t ",
|
||||
BatchSize: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
payload, err := client.GetRemoteConfigPayload("test-flag")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "ok", payload)
|
||||
|
||||
require.NoError(t, client.Enqueue(Capture{DistinctId: "test-user", Event: "test-event"}))
|
||||
require.NoError(t, client.Close())
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
require.Equal(t, "test-api-key", batchAPIKey)
|
||||
require.Equal(t, "test-api-key", remoteConfigToken)
|
||||
require.Equal(t, "Bearer test-personal-key", remoteConfigAuth)
|
||||
}
|
||||
|
||||
var _ Message = (*testErrorMessage)(nil)
|
||||
|
||||
// Instances of this type are used to force message validation errors in unit
|
||||
@@ -119,7 +324,7 @@ var (
|
||||
|
||||
testTransportFeatureFlagsOK = roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
var body string
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
body = fixture("feature_flag/test-simple-flag.json")
|
||||
}
|
||||
return &http.Response{
|
||||
@@ -184,6 +389,48 @@ func fixture(name string) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func assertPayloadEqual(t *testing.T, expected, actual string) {
|
||||
t.Helper()
|
||||
|
||||
sysCtx := getSystemContext().ToProperties()
|
||||
|
||||
var expectedJSON, actualJSON map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal([]byte(expected), &expectedJSON))
|
||||
require.NoError(t, json.Unmarshal([]byte(actual), &actualJSON))
|
||||
|
||||
batch, ok := actualJSON["batch"].([]interface{})
|
||||
require.True(t, ok, "actual JSON missing 'batch' array")
|
||||
for i, item := range batch {
|
||||
event, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
props, ok := event["properties"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for key := range sysCtx {
|
||||
if _, exists := props[key]; !exists {
|
||||
t.Errorf("batch[%d] missing %s in properties", i, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
knownSysCtxKeys := map[string]struct{}{
|
||||
"$os": {},
|
||||
"$os_version": {},
|
||||
"$os_distro": {},
|
||||
"$go_version": {},
|
||||
}
|
||||
opt := cmpopts.IgnoreMapEntries(func(k string, v interface{}) bool {
|
||||
_, ok := knownSysCtxKeys[k]
|
||||
return ok
|
||||
})
|
||||
if diff := cmp.Diff(expectedJSON, actualJSON, opt); diff != "" {
|
||||
t.Errorf("payload mismatch (-expected +actual):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func mockTime() time.Time {
|
||||
// time.Unix(0, 0) fails on Circle
|
||||
return time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC)
|
||||
@@ -231,7 +478,6 @@ func ExampleCapture() {
|
||||
Properties: Properties{
|
||||
"application": "Insights Go",
|
||||
"version": "1.0.0",
|
||||
"platform": "macos", // :)
|
||||
},
|
||||
SendFeatureFlags: SendFeatureFlags(false),
|
||||
})
|
||||
@@ -367,7 +613,6 @@ func TestEnqueue(t *testing.T) {
|
||||
Properties: Properties{
|
||||
"application": "Insights Go",
|
||||
"version": "1.0.0",
|
||||
"platform": "macos", // :)
|
||||
},
|
||||
SendFeatureFlags: SendFeatureFlags(false),
|
||||
},
|
||||
@@ -383,7 +628,6 @@ func TestEnqueue(t *testing.T) {
|
||||
Properties: Properties{
|
||||
"application": "Insights Go",
|
||||
"version": "1.0.0",
|
||||
"platform": "macos", // :)
|
||||
},
|
||||
SendFeatureFlags: SendFeatureFlags(false),
|
||||
},
|
||||
@@ -433,7 +677,6 @@ func TestEnqueue(t *testing.T) {
|
||||
Properties: Properties{
|
||||
"application": "Insights Go",
|
||||
"version": "1.0.0",
|
||||
"platform": "macos", // :)
|
||||
},
|
||||
SendFeatureFlags: SendFeatureFlags(false),
|
||||
},
|
||||
@@ -467,9 +710,7 @@ func TestEnqueue(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
if res := string(<-body); res != test.ref {
|
||||
t.Errorf("%s: invalid response:\n- expected %s\n- received: %s", name, test.ref, res)
|
||||
}
|
||||
assertPayloadEqual(t, test.ref, string(<-body))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -663,15 +904,12 @@ func TestCaptureWithInterval(t *testing.T) {
|
||||
Properties: Properties{
|
||||
"application": "Insights Go",
|
||||
"version": "1.0.0",
|
||||
"platform": "macos", // :)
|
||||
},
|
||||
SendFeatureFlags: SendFeatureFlags(false),
|
||||
})
|
||||
|
||||
// Will flush in 100 milliseconds
|
||||
if res := string(<-body); ref != res {
|
||||
t.Errorf("invalid response:\n- expected %s\n- received: %s", ref, res)
|
||||
}
|
||||
assertPayloadEqual(t, ref, string(<-body))
|
||||
|
||||
if t1 := time.Now(); t1.Sub(t0) < interval {
|
||||
t.Error("the flushing interval is too short:", interval)
|
||||
@@ -700,15 +938,12 @@ func TestCaptureWithTimestamp(t *testing.T) {
|
||||
Properties: Properties{
|
||||
"application": "Insights Go",
|
||||
"version": "1.0.0",
|
||||
"platform": "macos", // :)
|
||||
},
|
||||
SendFeatureFlags: SendFeatureFlags(false),
|
||||
Timestamp: time.Date(2015, time.July, 10, 23, 0, 0, 0, time.UTC),
|
||||
})
|
||||
|
||||
if res := string(<-body); ref != res {
|
||||
t.Errorf("invalid response:\n- expected %s\n- received: %s", ref, res)
|
||||
}
|
||||
assertPayloadEqual(t, ref, string(<-body))
|
||||
}
|
||||
|
||||
func TestCaptureWithDefaultProperties(t *testing.T) {
|
||||
@@ -734,15 +969,12 @@ func TestCaptureWithDefaultProperties(t *testing.T) {
|
||||
Properties: Properties{
|
||||
"application": "Insights Go",
|
||||
"version": "1.0.0",
|
||||
"platform": "macos", // :)
|
||||
},
|
||||
SendFeatureFlags: SendFeatureFlags(false),
|
||||
Timestamp: time.Date(2015, time.July, 10, 23, 0, 0, 0, time.UTC),
|
||||
})
|
||||
|
||||
if res := string(<-body); ref != res {
|
||||
t.Errorf("invalid response:\n- expected %s\n- received: %s", ref, res)
|
||||
}
|
||||
assertPayloadEqual(t, ref, string(<-body))
|
||||
}
|
||||
|
||||
func TestCaptureMany(t *testing.T) {
|
||||
@@ -781,9 +1013,7 @@ func TestCaptureMany(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
if res := string(<-body); ref != res {
|
||||
t.Errorf("invalid response:\n- expected %s\n- received: %s", ref, res)
|
||||
}
|
||||
assertPayloadEqual(t, ref, string(<-body))
|
||||
}
|
||||
|
||||
func TestClientCloseTwice(t *testing.T) {
|
||||
@@ -1025,8 +1255,15 @@ func TestFeatureFlagsWithNoPersonalApiKey(t *testing.T) {
|
||||
errchan := make(chan error, 1)
|
||||
defer close(errchan)
|
||||
|
||||
var logged []string
|
||||
client, err := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
|
||||
Logger: testLogger{t.Logf, t.Logf},
|
||||
PersonalApiKey: " \n\t ",
|
||||
Logger: testLogger{
|
||||
logf: func(format string, args ...interface{}) {
|
||||
logged = append(logged, fmt.Sprintf(format, args...))
|
||||
},
|
||||
errorf: t.Logf,
|
||||
},
|
||||
Callback: testCallback{
|
||||
func(m APIMessage) {},
|
||||
func(m APIMessage, e error) { errchan <- e },
|
||||
@@ -1034,9 +1271,70 @@ func TestFeatureFlagsWithNoPersonalApiKey(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.ErrorContains(t, client.ReloadFeatureFlags(), "no PersonalAPIKey provided")
|
||||
_, err = client.GetFeatureFlags()
|
||||
require.ErrorContains(t, err, "no PersonalAPIKey provided")
|
||||
require.ErrorIs(t, client.ReloadFeatureFlags(), ErrNoPersonalAPIKey)
|
||||
|
||||
payload, err := client.GetRemoteConfigPayload("test-flag")
|
||||
require.ErrorIs(t, err, ErrNoPersonalAPIKey)
|
||||
require.Empty(t, payload)
|
||||
|
||||
localFlag, err := client.GetFeatureFlag(FeatureFlagPayload{
|
||||
Key: "test-flag",
|
||||
DistinctId: "test-user",
|
||||
OnlyEvaluateLocally: true,
|
||||
})
|
||||
require.ErrorIs(t, err, ErrNoPersonalAPIKey)
|
||||
require.Nil(t, localFlag)
|
||||
|
||||
localFlags, err := client.GetAllFlags(FeatureFlagPayloadNoKey{
|
||||
DistinctId: "test-user",
|
||||
OnlyEvaluateLocally: true,
|
||||
})
|
||||
require.ErrorIs(t, err, ErrNoPersonalAPIKey)
|
||||
require.Nil(t, localFlags)
|
||||
|
||||
localEvaluations, err := client.EvaluateFlags(EvaluateFlagsPayload{
|
||||
DistinctId: "test-user",
|
||||
OnlyEvaluateLocally: true,
|
||||
})
|
||||
require.ErrorIs(t, err, ErrNoPersonalAPIKey)
|
||||
require.NotNil(t, localEvaluations)
|
||||
require.Empty(t, localEvaluations.Keys())
|
||||
|
||||
joinedLogs := strings.Join(logged, "\n")
|
||||
require.Contains(t, joinedLogs, "PostHog personal_api_key is not configured; ReloadFeatureFlags requires a PersonalApiKey.")
|
||||
require.Contains(t, joinedLogs, "PostHog personal_api_key is not configured; GetRemoteConfigPayload requires a PersonalApiKey.")
|
||||
require.Contains(t, joinedLogs, "PostHog personal_api_key is not configured; GetFeatureFlagResult requires a PersonalApiKey.")
|
||||
require.Contains(t, joinedLogs, "PostHog personal_api_key is not configured; GetAllFlags requires a PersonalApiKey.")
|
||||
require.Contains(t, joinedLogs, "PostHog personal_api_key is not configured; EvaluateFlags requires a PersonalApiKey.")
|
||||
}
|
||||
|
||||
func TestFeatureFlagPublicMethodsKeepDistinctIDConfigError(t *testing.T) {
|
||||
client, err := NewWithConfig("test-api-key", Config{})
|
||||
require.NoError(t, err)
|
||||
defer client.Close()
|
||||
|
||||
_, err = client.IsFeatureEnabled(FeatureFlagPayload{Key: "test-flag"})
|
||||
assertDistinctIDConfigError(t, err)
|
||||
|
||||
_, err = client.GetFeatureFlag(FeatureFlagPayload{Key: "test-flag"})
|
||||
assertDistinctIDConfigError(t, err)
|
||||
|
||||
_, err = client.GetFeatureFlagResult(FeatureFlagPayload{Key: "test-flag"})
|
||||
assertDistinctIDConfigError(t, err)
|
||||
|
||||
_, err = client.GetFeatureFlagPayload(FeatureFlagPayload{Key: "test-flag"})
|
||||
assertDistinctIDConfigError(t, err)
|
||||
|
||||
_, err = client.GetAllFlags(FeatureFlagPayloadNoKey{})
|
||||
assertDistinctIDConfigError(t, err)
|
||||
}
|
||||
|
||||
func assertDistinctIDConfigError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
var configErr ConfigError
|
||||
require.ErrorAs(t, err, &configErr)
|
||||
require.Equal(t, "DistinctId required", configErr.Reason)
|
||||
require.Equal(t, "Distinct Id", configErr.Field)
|
||||
}
|
||||
|
||||
func TestIsFeatureEnabled(t *testing.T) {
|
||||
@@ -1141,7 +1439,7 @@ func TestDeviceIdInFlagsRequest(t *testing.T) {
|
||||
t.Run("GetFeatureFlag passes device_id when provided", func(t *testing.T) {
|
||||
var requestData FlagsRequestData
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
json.Unmarshal(body, &requestData)
|
||||
w.Write([]byte(`{"featureFlags": {"test-flag": true}}`))
|
||||
@@ -1174,7 +1472,7 @@ func TestDeviceIdInFlagsRequest(t *testing.T) {
|
||||
t.Run("GetFeatureFlag omits device_id when nil", func(t *testing.T) {
|
||||
var receivedBody string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
receivedBody = string(body)
|
||||
w.Write([]byte(`{"featureFlags": {"test-flag": true}}`))
|
||||
@@ -1203,7 +1501,7 @@ func TestDeviceIdInFlagsRequest(t *testing.T) {
|
||||
t.Run("GetAllFlags passes device_id when provided", func(t *testing.T) {
|
||||
var requestData FlagsRequestData
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
json.Unmarshal(body, &requestData)
|
||||
w.Write([]byte(`{"featureFlags": {"test-flag": true}}`))
|
||||
@@ -1235,7 +1533,7 @@ func TestDeviceIdInFlagsRequest(t *testing.T) {
|
||||
t.Run("GetFeatureFlagPayload passes device_id when provided", func(t *testing.T) {
|
||||
var requestData FlagsRequestData
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
json.Unmarshal(body, &requestData)
|
||||
w.Write([]byte(`{"featureFlags": {"test-flag": true}, "featureFlagPayloads": {"test-flag": "payload-value"}}`))
|
||||
@@ -1270,14 +1568,14 @@ func TestDeviceIdInFlagsRequest(t *testing.T) {
|
||||
received := make(chan struct{})
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/flags"):
|
||||
case r.URL.Path == "/flags" || r.URL.Path == "/flags/":
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if err := json.Unmarshal(body, &requestData); err != nil {
|
||||
t.Errorf("Failed to parse request body: %v", err)
|
||||
}
|
||||
close(received)
|
||||
w.Write([]byte(`{"featureFlags": {"test-flag": true}}`))
|
||||
case strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation"):
|
||||
case strings.HasPrefix(r.URL.Path, "/flags/definitions"):
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"flags":[]}`))
|
||||
case strings.HasPrefix(r.URL.Path, "/batch"):
|
||||
@@ -1325,7 +1623,7 @@ func TestDeviceIdInFlagsRequest(t *testing.T) {
|
||||
|
||||
func TestGetFeatureFlagPayloadWithNoPersonalApiKey(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if !strings.HasPrefix(r.URL.Path, "/batch") {
|
||||
t.Errorf("client called an endpoint it shouldn't have: %s", r.URL.Path)
|
||||
@@ -1511,7 +1809,7 @@ func TestGetFeatureFlagPayloadWithNoPersonalApiKey(t *testing.T) {
|
||||
|
||||
func TestGetFeatureFlagWithNoPersonalApiKey(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if !strings.HasPrefix(r.URL.Path, "/batch") {
|
||||
t.Errorf("client called an endpoint it shouldn't have: %s", r.URL.Path)
|
||||
@@ -1584,6 +1882,9 @@ func TestGetFeatureFlagWithNoPersonalApiKey(t *testing.T) {
|
||||
if lastEvent.Properties["$feature_flag_response"] != expectedValue {
|
||||
t.Errorf("Expected feature flag response %v, got: %v", expectedValue, lastEvent.Properties["$feature_flag_response"])
|
||||
}
|
||||
if lastEvent.Properties["locally_evaluated"] != false {
|
||||
t.Errorf("Expected locally_evaluated to be false for remote evaluation, got: %v", lastEvent.Properties["locally_evaluated"])
|
||||
}
|
||||
}
|
||||
|
||||
// Test a bunch of GetFeatureFlag scenarios
|
||||
@@ -1875,7 +2176,7 @@ func TestGetAllFeatureFlagsWithNoPersonalApiKey(t *testing.T) {
|
||||
|
||||
func TestGetFeatureFlagPayloadWithPersonalKey(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
t.Fatal("expected local evaluations endpoint to be called")
|
||||
}
|
||||
w.Write([]byte(fixture("test-api-feature-flag.json")))
|
||||
@@ -1905,13 +2206,13 @@ func TestGetFeatureFlagPayloadWithPersonalKey(t *testing.T) {
|
||||
func TestGetFeatureFlagPayloadWithPersonalKey_LocalComputationFailure(t *testing.T) {
|
||||
apiCalls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if apiCalls == 0 && strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if apiCalls == 0 && (r.URL.Path == "/flags" || r.URL.Path == "/flags/") {
|
||||
t.Fatal("expected local evaluations endpoint to be called first")
|
||||
} else if apiCalls == 1 && strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if apiCalls == 1 && strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
t.Fatal("expected flags endpoint to be called second")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("test-api-feature-flag.json")))
|
||||
} else {
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
@@ -1974,9 +2275,9 @@ func TestSimpleFlagCalculation(t *testing.T) {
|
||||
|
||||
func TestComplexFlag(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte(fixture("test-api-feature-flag.json")))
|
||||
} else if !strings.HasPrefix(r.URL.Path, "/batch") {
|
||||
t.Errorf("client called an endpoint it shouldn't have")
|
||||
@@ -2026,9 +2327,9 @@ func TestComplexFlag(t *testing.T) {
|
||||
|
||||
func TestMultiVariateFlag(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte("{}"))
|
||||
} else if !strings.HasPrefix(r.URL.Path, "/batch") {
|
||||
t.Errorf("client called an endpoint it shouldn't have")
|
||||
@@ -2078,9 +2379,9 @@ func TestMultiVariateFlag(t *testing.T) {
|
||||
|
||||
func TestDisabledFlag(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.Write([]byte(fixture("test-flags-v3.json")))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.Write([]byte("{}"))
|
||||
} else if !strings.HasPrefix(r.URL.Path, "/batch") {
|
||||
t.Errorf("client called an endpoint it shouldn't have")
|
||||
@@ -2316,7 +2617,7 @@ func TestSendFeatureFlagsHelperMethods(t *testing.T) {
|
||||
func TestFeatureFlagQuotaLimits(t *testing.T) {
|
||||
t.Run("flags endpoint quota limited", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{
|
||||
"featureFlags": {"test-flag": true},
|
||||
@@ -2370,14 +2671,14 @@ func TestFeatureFlagQuotaLimits(t *testing.T) {
|
||||
|
||||
t.Run("local evaluation endpoint quota limited", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
||||
w.WriteHeader(http.StatusPaymentRequired)
|
||||
w.Write([]byte(`{
|
||||
"type": "quota_limited",
|
||||
"detail": "You have exceeded your feature flag request quota",
|
||||
"code": "payment_required"
|
||||
}`))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
} else if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
|
||||
// Mock the flags endpoint as well since it's used as fallback
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{
|
||||
@@ -2436,7 +2737,7 @@ func TestClient_GetRemoteConfigPayload_IncludesTokenParameter(t *testing.T) {
|
||||
var remoteConfigCalled bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Handle the initial feature flag definitions request
|
||||
if strings.Contains(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
if strings.Contains(r.URL.Path, "/flags/definitions") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"flags": [], "group_type_mapping": {}}`))
|
||||
return
|
||||
|
||||
+13
-13
@@ -10,21 +10,19 @@ import (
|
||||
// Values implementing this interface are used by insights clients to notify
|
||||
// the application when a message send succeeded or failed.
|
||||
//
|
||||
// Callback methods are called by a client's internal goroutines, there are no
|
||||
// guarantees on which goroutine will trigger the callbacks, the calls can be
|
||||
// made sequentially or in parallel, the order doesn't depend on the order of
|
||||
// messages were queued to the client.
|
||||
// Callback methods are called by a client's internal goroutines. There are no
|
||||
// guarantees about which goroutine triggers the callbacks, whether calls are
|
||||
// made sequentially or in parallel, or whether callback order matches enqueue order.
|
||||
//
|
||||
// Callback methods must return quickly and not cause long blocking operations
|
||||
// to avoid interferring with the client's internal work flow.
|
||||
// Callback methods must return quickly and avoid long blocking operations so
|
||||
// they do not interfere with the client's internal workflow.
|
||||
type Callback interface {
|
||||
|
||||
// This method is called for every message that was successfully sent to
|
||||
// the API.
|
||||
// Success is called for every message that was successfully sent to the API.
|
||||
Success(APIMessage)
|
||||
|
||||
// This method is called for every message that failed to be sent to the
|
||||
// API and will be discarded by the client.
|
||||
// Failure is called for every message that failed to be sent to the API and
|
||||
// will be discarded by the client. The error describes the send or serialization failure.
|
||||
Failure(APIMessage, error)
|
||||
}
|
||||
|
||||
@@ -35,12 +33,13 @@ type Callback interface {
|
||||
// and therefore can be passed to the insights.Client.Enqueue method.
|
||||
type Message interface {
|
||||
|
||||
// Validate validates the internal structure of the message, the method must return
|
||||
// nil if the message is valid, or an error describing what went wrong.
|
||||
// Validate checks the internal structure of the message. It returns nil when
|
||||
// the message is valid or an error describing the invalid field.
|
||||
Validate() error
|
||||
// APIfy converts the message into its PostHog batch API representation.
|
||||
APIfy() APIMessage
|
||||
|
||||
// internal is an unexposed interface function to ensure only types defined within this package can satisfy the Message interface. Invoking this method will panic.
|
||||
// internal prevents external packages from satisfying Message. Calling it panics.
|
||||
internal()
|
||||
}
|
||||
|
||||
@@ -71,6 +70,7 @@ type batch struct {
|
||||
Messages []json.RawMessage `json:"batch"`
|
||||
}
|
||||
|
||||
// APIMessage is a wire-format message produced by Message.APIfy and passed to callbacks.
|
||||
type APIMessage interface{}
|
||||
|
||||
// prepareForSend creates the API message and serializes it to JSON.
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ func TestEnqueue_InvalidMessageTypes(t *testing.T) {
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
t.Run("capture_missing_distinct_id", func(t *testing.T) {
|
||||
t.Run("capture_missing_distinct_id_without_request_context", func(t *testing.T) {
|
||||
err := client.Enqueue(Capture{
|
||||
Event: "test_event",
|
||||
})
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package posthog
|
||||
|
||||
import "context"
|
||||
|
||||
type noopClient struct {
|
||||
Config
|
||||
}
|
||||
|
||||
var (
|
||||
emptyFlagValues = map[string]interface{}{}
|
||||
emptyEvaluatedFlagRecords = map[string]evaluatedFlagRecord{}
|
||||
noopFeatureFlagResult = &FeatureFlagResult{Enabled: false}
|
||||
noopFeatureFlagEvaluations = &FeatureFlagEvaluations{flags: emptyEvaluatedFlagRecords}
|
||||
)
|
||||
|
||||
func newNoopClient(config Config) Client {
|
||||
return &noopClient{Config: config}
|
||||
}
|
||||
|
||||
func (c *noopClient) Enqueue(Message) error {
|
||||
return ErrSDKDisabled
|
||||
}
|
||||
|
||||
func (c *noopClient) EnqueueWithContext(context.Context, Message) error {
|
||||
return ErrSDKDisabled
|
||||
}
|
||||
|
||||
func (c *noopClient) IsFeatureEnabled(FeatureFlagPayload) (interface{}, error) {
|
||||
return false, ErrSDKDisabled
|
||||
}
|
||||
|
||||
func (c *noopClient) GetFeatureFlag(FeatureFlagPayload) (interface{}, error) {
|
||||
return false, ErrSDKDisabled
|
||||
}
|
||||
|
||||
func (c *noopClient) GetFeatureFlagResult(FeatureFlagPayload) (*FeatureFlagResult, error) {
|
||||
return noopFeatureFlagResult, ErrSDKDisabled
|
||||
}
|
||||
|
||||
func (c *noopClient) GetFeatureFlagPayload(FeatureFlagPayload) (string, error) {
|
||||
return "", ErrSDKDisabled
|
||||
}
|
||||
|
||||
func (c *noopClient) GetRemoteConfigPayload(string) (string, error) {
|
||||
return "", ErrSDKDisabled
|
||||
}
|
||||
|
||||
func (c *noopClient) GetAllFlags(FeatureFlagPayloadNoKey) (map[string]interface{}, error) {
|
||||
return emptyFlagValues, ErrSDKDisabled
|
||||
}
|
||||
|
||||
func (c *noopClient) EvaluateFlags(EvaluateFlagsPayload) (*FeatureFlagEvaluations, error) {
|
||||
return noopFeatureFlagEvaluations, ErrSDKDisabled
|
||||
}
|
||||
|
||||
func (c *noopClient) EvaluateFlagsWithContext(context.Context, EvaluateFlagsPayload) (*FeatureFlagEvaluations, error) {
|
||||
return noopFeatureFlagEvaluations, ErrSDKDisabled
|
||||
}
|
||||
|
||||
func (c *noopClient) ReloadFeatureFlags() error {
|
||||
return ErrSDKDisabled
|
||||
}
|
||||
|
||||
func (c *noopClient) Close() error {
|
||||
return ErrSDKDisabled
|
||||
}
|
||||
|
||||
func (c *noopClient) CloseWithContext(context.Context) error {
|
||||
return ErrSDKDisabled
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build darwin
|
||||
|
||||
package posthog
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func getOSInfo() osInfo {
|
||||
out, err := exec.Command("sw_vers", "-productVersion").Output()
|
||||
if err != nil {
|
||||
return osInfo{
|
||||
Name: "Mac OS X",
|
||||
}
|
||||
}
|
||||
return osInfo{
|
||||
Name: "Mac OS X",
|
||||
Version: strings.TrimSpace(string(out)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//go:build linux
|
||||
|
||||
package posthog
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func getOSInfo() osInfo {
|
||||
data, err := os.ReadFile("/etc/os-release")
|
||||
if err != nil {
|
||||
return osInfo{
|
||||
Name: "Linux",
|
||||
}
|
||||
}
|
||||
|
||||
fields := make(map[string]string)
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
fields[parts[0]] = strings.Trim(parts[1], `"`)
|
||||
}
|
||||
}
|
||||
|
||||
return osInfo{
|
||||
Name: "Linux",
|
||||
Version: fields["VERSION_ID"],
|
||||
Distro: fields["NAME"],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build !linux && !darwin && !windows
|
||||
|
||||
package posthog
|
||||
|
||||
import "runtime"
|
||||
|
||||
var osNameMap = map[string]string{
|
||||
"freebsd": "FreeBSD",
|
||||
"android": "Android",
|
||||
"ios": "iOS",
|
||||
}
|
||||
|
||||
func getOSInfo() osInfo {
|
||||
name := runtime.GOOS
|
||||
if mapped, ok := osNameMap[runtime.GOOS]; ok {
|
||||
name = mapped
|
||||
}
|
||||
return osInfo{
|
||||
Name: name,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build windows
|
||||
|
||||
package posthog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func getOSInfo() osInfo {
|
||||
ver := windows.RtlGetVersion()
|
||||
return osInfo{
|
||||
Name: "Windows",
|
||||
Version: fmt.Sprintf("%d.%d.%d", ver.MajorVersion, ver.MinorVersion, ver.BuildNumber),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "posthog-go",
|
||||
"version": "1.13.1",
|
||||
"private": true,
|
||||
"description": "Release metadata and changesets for the PostHog Go SDK",
|
||||
"packageManager": "[email protected]",
|
||||
"scripts": {
|
||||
"changeset": "changeset"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.30.0"
|
||||
}
|
||||
}
|
||||
Generated
+805
@@ -0,0 +1,805 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
'@changesets/cli':
|
||||
specifier: ^2.30.0
|
||||
version: 2.31.0
|
||||
|
||||
packages:
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@changesets/[email protected]':
|
||||
resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==}
|
||||
|
||||
'@changesets/[email protected]':
|
||||
resolution: {integrity: sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A==}
|
||||
|
||||
'@changesets/[email protected]':
|
||||
resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==}
|
||||
|
||||
'@changesets/[email protected]':
|
||||
resolution: {integrity: sha512-AhI4enNTgHu2IZr6K4WZyf0EPch4XVMn1yOMFmCD9gsfBGqMYaHXls5HyDv6/CL5axVQABz68eG30eCtbr2wFg==}
|
||||
hasBin: true
|
||||
|
||||
'@changesets/[email protected]':
|
||||
resolution: {integrity: sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q==}
|
||||
|
||||
'@changesets/[email protected]':
|
||||
resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==}
|
||||
|
||||
'@changesets/[email protected]':
|
||||
resolution: {integrity: sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg==}
|
||||
|
||||
'@changesets/[email protected]':
|
||||
resolution: {integrity: sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g==}
|
||||
|
||||
'@changesets/[email protected]':
|
||||
resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==}
|
||||
|
||||
'@changesets/[email protected]':
|
||||
resolution: {integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==}
|
||||
|
||||
'@changesets/[email protected]':
|
||||
resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==}
|
||||
|
||||
'@changesets/[email protected]':
|
||||
resolution: {integrity: sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A==}
|
||||
|
||||
'@changesets/[email protected]':
|
||||
resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==}
|
||||
|
||||
'@changesets/[email protected]':
|
||||
resolution: {integrity: sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA==}
|
||||
|
||||
'@changesets/[email protected]':
|
||||
resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==}
|
||||
|
||||
'@changesets/[email protected]':
|
||||
resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==}
|
||||
|
||||
'@changesets/[email protected]':
|
||||
resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==}
|
||||
|
||||
'@changesets/[email protected]':
|
||||
resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==}
|
||||
|
||||
'@inquirer/[email protected]':
|
||||
resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@manypkg/[email protected]':
|
||||
resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==}
|
||||
|
||||
'@manypkg/[email protected]':
|
||||
resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==}
|
||||
|
||||
'@nodelib/[email protected]':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
'@nodelib/[email protected]':
|
||||
resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
'@nodelib/[email protected]':
|
||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==}
|
||||
engines: {node: '>=6 <7 || >=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
|
||||
engines: {node: '>=6 <7 || >=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
||||
engines: {node: '>=0.12.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
|
||||
engines: {node: '>= 4.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/[email protected]': {}
|
||||
|
||||
'@changesets/[email protected]':
|
||||
dependencies:
|
||||
'@changesets/config': 3.1.4
|
||||
'@changesets/get-version-range-type': 0.4.0
|
||||
'@changesets/git': 3.0.4
|
||||
'@changesets/should-skip-package': 0.1.2
|
||||
'@changesets/types': 6.1.0
|
||||
'@manypkg/get-packages': 1.1.3
|
||||
detect-indent: 6.1.0
|
||||
fs-extra: 7.0.1
|
||||
lodash.startcase: 4.4.0
|
||||
outdent: 0.5.0
|
||||
prettier: 2.8.8
|
||||
resolve-from: 5.0.0
|
||||
semver: 7.7.4
|
||||
|
||||
'@changesets/[email protected]':
|
||||
dependencies:
|
||||
'@changesets/errors': 0.2.0
|
||||
'@changesets/get-dependents-graph': 2.1.4
|
||||
'@changesets/should-skip-package': 0.1.2
|
||||
'@changesets/types': 6.1.0
|
||||
'@manypkg/get-packages': 1.1.3
|
||||
semver: 7.7.4
|
||||
|
||||
'@changesets/[email protected]':
|
||||
dependencies:
|
||||
'@changesets/types': 6.1.0
|
||||
|
||||
'@changesets/[email protected]':
|
||||
dependencies:
|
||||
'@changesets/apply-release-plan': 7.1.1
|
||||
'@changesets/assemble-release-plan': 6.0.10
|
||||
'@changesets/changelog-git': 0.2.1
|
||||
'@changesets/config': 3.1.4
|
||||
'@changesets/errors': 0.2.0
|
||||
'@changesets/get-dependents-graph': 2.1.4
|
||||
'@changesets/get-release-plan': 4.0.16
|
||||
'@changesets/git': 3.0.4
|
||||
'@changesets/logger': 0.1.1
|
||||
'@changesets/pre': 2.0.2
|
||||
'@changesets/read': 0.6.7
|
||||
'@changesets/should-skip-package': 0.1.2
|
||||
'@changesets/types': 6.1.0
|
||||
'@changesets/write': 0.4.0
|
||||
'@inquirer/external-editor': 1.0.3
|
||||
'@manypkg/get-packages': 1.1.3
|
||||
ansi-colors: 4.1.3
|
||||
enquirer: 2.4.1
|
||||
fs-extra: 7.0.1
|
||||
mri: 1.2.0
|
||||
package-manager-detector: 0.2.11
|
||||
picocolors: 1.1.1
|
||||
resolve-from: 5.0.0
|
||||
semver: 7.7.4
|
||||
spawndamnit: 3.0.1
|
||||
term-size: 2.2.1
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
|
||||
'@changesets/[email protected]':
|
||||
dependencies:
|
||||
'@changesets/errors': 0.2.0
|
||||
'@changesets/get-dependents-graph': 2.1.4
|
||||
'@changesets/logger': 0.1.1
|
||||
'@changesets/should-skip-package': 0.1.2
|
||||
'@changesets/types': 6.1.0
|
||||
'@manypkg/get-packages': 1.1.3
|
||||
fs-extra: 7.0.1
|
||||
micromatch: 4.0.8
|
||||
|
||||
'@changesets/[email protected]':
|
||||
dependencies:
|
||||
extendable-error: 0.1.7
|
||||
|
||||
'@changesets/[email protected]':
|
||||
dependencies:
|
||||
'@changesets/types': 6.1.0
|
||||
'@manypkg/get-packages': 1.1.3
|
||||
picocolors: 1.1.1
|
||||
semver: 7.7.4
|
||||
|
||||
'@changesets/[email protected]':
|
||||
dependencies:
|
||||
'@changesets/assemble-release-plan': 6.0.10
|
||||
'@changesets/config': 3.1.4
|
||||
'@changesets/pre': 2.0.2
|
||||
'@changesets/read': 0.6.7
|
||||
'@changesets/types': 6.1.0
|
||||
'@manypkg/get-packages': 1.1.3
|
||||
|
||||
'@changesets/[email protected]': {}
|
||||
|
||||
'@changesets/[email protected]':
|
||||
dependencies:
|
||||
'@changesets/errors': 0.2.0
|
||||
'@manypkg/get-packages': 1.1.3
|
||||
is-subdir: 1.2.0
|
||||
micromatch: 4.0.8
|
||||
spawndamnit: 3.0.1
|
||||
|
||||
'@changesets/[email protected]':
|
||||
dependencies:
|
||||
picocolors: 1.1.1
|
||||
|
||||
'@changesets/[email protected]':
|
||||
dependencies:
|
||||
'@changesets/types': 6.1.0
|
||||
js-yaml: 4.1.1
|
||||
|
||||
'@changesets/[email protected]':
|
||||
dependencies:
|
||||
'@changesets/errors': 0.2.0
|
||||
'@changesets/types': 6.1.0
|
||||
'@manypkg/get-packages': 1.1.3
|
||||
fs-extra: 7.0.1
|
||||
|
||||
'@changesets/[email protected]':
|
||||
dependencies:
|
||||
'@changesets/git': 3.0.4
|
||||
'@changesets/logger': 0.1.1
|
||||
'@changesets/parse': 0.4.3
|
||||
'@changesets/types': 6.1.0
|
||||
fs-extra: 7.0.1
|
||||
p-filter: 2.1.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
'@changesets/[email protected]':
|
||||
dependencies:
|
||||
'@changesets/types': 6.1.0
|
||||
'@manypkg/get-packages': 1.1.3
|
||||
|
||||
'@changesets/[email protected]': {}
|
||||
|
||||
'@changesets/[email protected]': {}
|
||||
|
||||
'@changesets/[email protected]':
|
||||
dependencies:
|
||||
'@changesets/types': 6.1.0
|
||||
fs-extra: 7.0.1
|
||||
human-id: 4.1.3
|
||||
prettier: 2.8.8
|
||||
|
||||
'@inquirer/[email protected]':
|
||||
dependencies:
|
||||
chardet: 2.1.1
|
||||
iconv-lite: 0.7.2
|
||||
|
||||
'@manypkg/[email protected]':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
'@types/node': 12.20.55
|
||||
find-up: 4.1.0
|
||||
fs-extra: 8.1.0
|
||||
|
||||
'@manypkg/[email protected]':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
'@changesets/types': 4.1.0
|
||||
'@manypkg/find-root': 1.1.0
|
||||
fs-extra: 8.1.0
|
||||
globby: 11.1.0
|
||||
read-yaml-file: 1.1.0
|
||||
|
||||
'@nodelib/[email protected]':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
run-parallel: 1.2.0
|
||||
|
||||
'@nodelib/[email protected]': {}
|
||||
|
||||
'@nodelib/[email protected]':
|
||||
dependencies:
|
||||
'@nodelib/fs.scandir': 2.1.5
|
||||
fastq: 1.20.1
|
||||
|
||||
'@types/[email protected]': {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
sprintf-js: 1.0.3
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
is-windows: 1.0.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
fill-range: 7.1.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
path-type: 4.0.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
ansi-colors: 4.1.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
'@nodelib/fs.walk': 1.2.8
|
||||
glob-parent: 5.1.2
|
||||
merge2: 1.4.1
|
||||
micromatch: 4.0.8
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
reusify: 1.1.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
locate-path: 5.0.0
|
||||
path-exists: 4.0.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
jsonfile: 4.0.0
|
||||
universalify: 0.1.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
jsonfile: 4.0.0
|
||||
universalify: 0.1.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
array-union: 2.1.0
|
||||
dir-glob: 3.0.1
|
||||
fast-glob: 3.3.3
|
||||
ignore: 5.3.2
|
||||
merge2: 1.4.1
|
||||
slash: 3.0.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
is-extglob: 2.1.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
better-path-resolve: 1.0.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
argparse: 1.0.10
|
||||
esprima: 4.0.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
[email protected]:
|
||||
optionalDependencies:
|
||||
graceful-fs: 4.2.11
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
p-locate: 4.1.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
braces: 3.0.3
|
||||
picomatch: 2.3.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
p-map: 2.1.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
p-try: 2.2.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
p-limit: 2.3.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
quansync: 0.2.11
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
js-yaml: 3.14.2
|
||||
pify: 4.0.1
|
||||
strip-bom: 3.0.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
shebang-regex: 3.0.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
signal-exit: 4.1.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
@@ -0,0 +1,4 @@
|
||||
packages:
|
||||
- '.'
|
||||
|
||||
minimumReleaseAge: 10080
|
||||
@@ -14,10 +14,12 @@ package insights
|
||||
// }
|
||||
type Properties map[string]interface{}
|
||||
|
||||
// NewProperties creates an empty Properties map for fluent construction.
|
||||
func NewProperties() Properties {
|
||||
return make(Properties, 10)
|
||||
}
|
||||
|
||||
// Set assigns a property value and returns the receiver.
|
||||
func (p Properties) Set(name string, value interface{}) Properties {
|
||||
p[name] = value
|
||||
return p
|
||||
|
||||
@@ -0,0 +1,767 @@
|
||||
package posthog
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// HeaderPostHogDistinctID is the HTTP header used to propagate a PostHog distinct ID.
|
||||
HeaderPostHogDistinctID = "X-PostHog-Distinct-Id"
|
||||
// HeaderPostHogSessionID is the HTTP header used to propagate a PostHog session ID.
|
||||
HeaderPostHogSessionID = "X-PostHog-Session-Id"
|
||||
|
||||
propertyProcessPersonProfile = "$process_person_profile"
|
||||
propertySessionID = "$session_id"
|
||||
propertyCurrentURL = "$current_url"
|
||||
propertyRequestMethod = "$request_method"
|
||||
propertyRequestPath = "$request_path"
|
||||
propertyUserAgent = "$user_agent"
|
||||
propertyIP = "$ip"
|
||||
propertyResponseStatusCode = "$response_status_code"
|
||||
propertyExceptionSource = "$exception_source"
|
||||
|
||||
maxRequestContextValueLength = 1000
|
||||
)
|
||||
|
||||
type requestContextKey struct{}
|
||||
|
||||
// RequestContext carries request-scoped PostHog metadata that is applied to capture and exception events
|
||||
// enqueued with EnqueueWithContext.
|
||||
//
|
||||
// DistinctId and SessionId usually come from PostHog tracing headers. Properties usually contain safe
|
||||
// request metadata such as $current_url, $request_method, $request_path, $user_agent, and $ip.
|
||||
type RequestContext struct {
|
||||
// DistinctId is the request-scoped user distinct ID.
|
||||
DistinctId string
|
||||
// SessionId is the request-scoped PostHog session ID.
|
||||
SessionId string
|
||||
// Properties are request-scoped properties merged into capture and exception events.
|
||||
Properties Properties
|
||||
}
|
||||
|
||||
// WithRequestContext returns a child context with PostHog request metadata attached. Existing PostHog
|
||||
// request context values are inherited: non-empty DistinctId and SessionId override the parent values,
|
||||
// and Properties are merged with child properties taking precedence.
|
||||
func WithRequestContext(ctx context.Context, requestContext RequestContext) context.Context {
|
||||
return withRequestContext(ctx, requestContext, false)
|
||||
}
|
||||
|
||||
// WithFreshRequestContext returns a child context with PostHog request metadata attached without inheriting
|
||||
// any existing PostHog request context from the parent.
|
||||
func WithFreshRequestContext(ctx context.Context, requestContext RequestContext) context.Context {
|
||||
return withRequestContext(ctx, requestContext, true)
|
||||
}
|
||||
|
||||
func withRequestContext(ctx context.Context, requestContext RequestContext, fresh bool) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
var base RequestContext
|
||||
if !fresh {
|
||||
base, _ = RequestContextFromContext(ctx)
|
||||
}
|
||||
|
||||
merged := RequestContext{
|
||||
DistinctId: sanitizeRequestContextValue(base.DistinctId),
|
||||
SessionId: sanitizeRequestContextValue(base.SessionId),
|
||||
Properties: cloneRequestProperties(base.Properties),
|
||||
}
|
||||
|
||||
if distinctID := sanitizeRequestContextValue(requestContext.DistinctId); distinctID != "" {
|
||||
merged.DistinctId = distinctID
|
||||
}
|
||||
if sessionID := sanitizeRequestContextValue(requestContext.SessionId); sessionID != "" {
|
||||
merged.SessionId = sessionID
|
||||
}
|
||||
if len(requestContext.Properties) > 0 {
|
||||
if merged.Properties == nil {
|
||||
merged.Properties = NewProperties()
|
||||
}
|
||||
for key, value := range requestContext.Properties {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
merged.Properties[key] = sanitizePropertyValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
return context.WithValue(ctx, requestContextKey{}, merged)
|
||||
}
|
||||
|
||||
// RequestContextFromContext returns the PostHog request metadata attached to ctx, if any.
|
||||
func RequestContextFromContext(ctx context.Context) (RequestContext, bool) {
|
||||
if ctx == nil {
|
||||
return RequestContext{}, false
|
||||
}
|
||||
requestContext, ok := ctx.Value(requestContextKey{}).(RequestContext)
|
||||
if !ok {
|
||||
return RequestContext{}, false
|
||||
}
|
||||
return RequestContext{
|
||||
DistinctId: sanitizeRequestContextValue(requestContext.DistinctId),
|
||||
SessionId: sanitizeRequestContextValue(requestContext.SessionId),
|
||||
Properties: cloneRequestProperties(requestContext.Properties),
|
||||
}, true
|
||||
}
|
||||
|
||||
// RequestContextOptions configures NewRequestContextMiddleware.
|
||||
type RequestContextOptions struct {
|
||||
// CaptureTracingHeaders controls whether X-PostHog-Distinct-Id and X-PostHog-Session-Id
|
||||
// are read into request-scoped analytics context. It defaults to true in NewRequestContextMiddleware.
|
||||
// When disabled, the middleware still attaches request metadata to the request context.
|
||||
CaptureTracingHeaders bool
|
||||
|
||||
// CapturePanics controls whether NewRequestContextMiddleware captures downstream panics as
|
||||
// PostHog exception events before re-panicking. It defaults to false.
|
||||
CapturePanics bool
|
||||
|
||||
// PanicCaptureClient receives panic exception events when CapturePanics is enabled.
|
||||
PanicCaptureClient EnqueueClient
|
||||
}
|
||||
|
||||
// RequestContextOption customizes request context middleware.
|
||||
type RequestContextOption func(*RequestContextOptions)
|
||||
|
||||
// WithCaptureTracingHeaders configures whether request context middleware reads PostHog tracing headers.
|
||||
func WithCaptureTracingHeaders(enabled bool) RequestContextOption {
|
||||
return func(options *RequestContextOptions) {
|
||||
options.CaptureTracingHeaders = enabled
|
||||
}
|
||||
}
|
||||
|
||||
// WithCapturePanics configures request context middleware to capture downstream panics as
|
||||
// PostHog exception events using client, then re-panic with the original value.
|
||||
func WithCapturePanics(client EnqueueClient) RequestContextOption {
|
||||
return func(options *RequestContextOptions) {
|
||||
options.CapturePanics = client != nil
|
||||
options.PanicCaptureClient = client
|
||||
}
|
||||
}
|
||||
|
||||
func defaultRequestContextOptions() RequestContextOptions {
|
||||
return RequestContextOptions{CaptureTracingHeaders: true}
|
||||
}
|
||||
|
||||
// NewRequestContextMiddleware wraps an http.Handler with PostHog request-context extraction.
|
||||
//
|
||||
// The middleware always attaches safe request metadata to r.Context(). By default it also reads
|
||||
// X-PostHog-Distinct-Id and X-PostHog-Session-Id into request-scoped analytics context. Use
|
||||
// EnqueueWithContext(r.Context(), ...) for captures inside the request so this metadata is applied.
|
||||
func NewRequestContextMiddleware(next http.Handler, opts ...RequestContextOption) http.Handler {
|
||||
options := defaultRequestContextOptions()
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(&options)
|
||||
}
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if next == nil {
|
||||
return
|
||||
}
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
|
||||
requestContext := ExtractRequestContext(r, options.CaptureTracingHeaders)
|
||||
ctx := WithFreshRequestContext(r.Context(), requestContext)
|
||||
req := r.WithContext(ctx)
|
||||
|
||||
if !options.CapturePanics {
|
||||
next.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
statusWriter := newResponseStatusWriter(w)
|
||||
completed := false
|
||||
defer func() {
|
||||
if completed {
|
||||
return
|
||||
}
|
||||
|
||||
panicStack := debug.Stack()
|
||||
panicValue := recover()
|
||||
if panicValue == nil {
|
||||
return
|
||||
}
|
||||
capturePanic(ctx, options.PanicCaptureClient, panicValue, statusWriter.statusCode, panicStack)
|
||||
panic(panicValue)
|
||||
}()
|
||||
|
||||
next.ServeHTTP(wrapResponseStatusWriter(statusWriter), req)
|
||||
completed = true
|
||||
})
|
||||
}
|
||||
|
||||
// ExtractRequestContext extracts sanitized PostHog request context from an HTTP request. Request metadata
|
||||
// is always extracted. Tracing headers are only read when captureTracingHeaders is true.
|
||||
func ExtractRequestContext(r *http.Request, captureTracingHeaders bool) RequestContext {
|
||||
if r == nil {
|
||||
return RequestContext{Properties: NewProperties()}
|
||||
}
|
||||
|
||||
properties := NewProperties()
|
||||
addPropertyIfPresent(properties, propertyCurrentURL, currentURL(r))
|
||||
addPropertyIfPresent(properties, propertyRequestMethod, r.Method)
|
||||
addPropertyIfPresent(properties, propertyRequestPath, requestPath(r))
|
||||
addPropertyIfPresent(properties, propertyUserAgent, r.Header.Get("User-Agent"))
|
||||
addPropertyIfPresent(properties, propertyIP, remoteIP(r))
|
||||
|
||||
var distinctID, sessionID string
|
||||
if captureTracingHeaders {
|
||||
distinctID = sanitizeRequestContextValue(r.Header.Get(HeaderPostHogDistinctID))
|
||||
sessionID = sanitizeRequestContextValue(r.Header.Get(HeaderPostHogSessionID))
|
||||
}
|
||||
|
||||
return RequestContext{
|
||||
DistinctId: distinctID,
|
||||
SessionId: sessionID,
|
||||
Properties: properties,
|
||||
}
|
||||
}
|
||||
|
||||
type captureContext struct {
|
||||
distinctID string
|
||||
properties Properties
|
||||
generatedPersonlessDistinctID bool
|
||||
personlessProcessProfileGuard bool
|
||||
}
|
||||
|
||||
func resolveCaptureContext(ctx context.Context, distinctID string, properties Properties, fieldType string) (captureContext, error) {
|
||||
requestContext, hasRequestContext := RequestContextFromContext(ctx)
|
||||
resolvedDistinctID := distinctID
|
||||
isPersonless := false
|
||||
if resolvedDistinctID == "" && hasRequestContext {
|
||||
resolvedDistinctID = requestContext.DistinctId
|
||||
}
|
||||
if resolvedDistinctID == "" {
|
||||
if !hasRequestContext {
|
||||
return captureContext{}, FieldError{
|
||||
Type: fieldType,
|
||||
Name: "DistinctId",
|
||||
Value: distinctID,
|
||||
}
|
||||
}
|
||||
resolvedDistinctID = makeUUID("")
|
||||
isPersonless = true
|
||||
}
|
||||
|
||||
resolved := captureContext{
|
||||
distinctID: resolvedDistinctID,
|
||||
properties: mergeContextProperties(requestContext, properties),
|
||||
generatedPersonlessDistinctID: isPersonless,
|
||||
}
|
||||
if isPersonless {
|
||||
if resolved.properties == nil {
|
||||
resolved.properties = NewProperties()
|
||||
}
|
||||
if _, exists := resolved.properties[propertyProcessPersonProfile]; !exists {
|
||||
resolved.properties[propertyProcessPersonProfile] = false
|
||||
resolved.personlessProcessProfileGuard = true
|
||||
}
|
||||
}
|
||||
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func mergeContextProperties(requestContext RequestContext, explicit Properties) Properties {
|
||||
var merged Properties
|
||||
if len(requestContext.Properties) > 0 {
|
||||
merged = cloneRequestProperties(requestContext.Properties)
|
||||
}
|
||||
if requestContext.SessionId != "" {
|
||||
if merged == nil {
|
||||
merged = NewProperties()
|
||||
}
|
||||
if _, exists := merged[propertySessionID]; !exists {
|
||||
merged[propertySessionID] = requestContext.SessionId
|
||||
}
|
||||
}
|
||||
if len(explicit) > 0 {
|
||||
if merged == nil {
|
||||
merged = NewProperties()
|
||||
}
|
||||
for key, value := range explicit {
|
||||
merged[key] = value
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func cloneRequestProperties(properties Properties) Properties {
|
||||
if len(properties) == 0 {
|
||||
return nil
|
||||
}
|
||||
cloned := make(Properties, len(properties))
|
||||
for key, value := range properties {
|
||||
cloned[key] = value
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func sanitizePropertyValue(value interface{}) interface{} {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
if stringValue, ok := value.(string); ok {
|
||||
return sanitizeRequestContextValue(stringValue)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func addPropertyIfPresent(properties Properties, key, value string) {
|
||||
if value = sanitizeRequestContextValue(value); value != "" {
|
||||
properties.Set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeRequestContextValue(value string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
builder := strings.Builder{}
|
||||
builder.Grow(len(value))
|
||||
for _, character := range value {
|
||||
if character < 0x20 || character == 0x7f {
|
||||
continue
|
||||
}
|
||||
builder.WriteRune(character)
|
||||
}
|
||||
|
||||
sanitized := strings.TrimSpace(builder.String())
|
||||
if sanitized == "" {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(sanitized)
|
||||
if len(runes) > maxRequestContextValueLength {
|
||||
return string(runes[:maxRequestContextValueLength])
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func currentURL(r *http.Request) string {
|
||||
scheme := requestScheme(r)
|
||||
host := requestHost(r)
|
||||
if scheme == "" || host == "" {
|
||||
return ""
|
||||
}
|
||||
return scheme + "://" + host + requestPath(r)
|
||||
}
|
||||
|
||||
func requestScheme(r *http.Request) string {
|
||||
if r.URL != nil && r.URL.Scheme != "" {
|
||||
return r.URL.Scheme
|
||||
}
|
||||
if r.TLS != nil {
|
||||
return "https"
|
||||
}
|
||||
return "http"
|
||||
}
|
||||
|
||||
func requestHost(r *http.Request) string {
|
||||
if r.Host != "" {
|
||||
return r.Host
|
||||
}
|
||||
if r.URL != nil {
|
||||
return r.URL.Host
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func requestPath(r *http.Request) string {
|
||||
if r.URL == nil {
|
||||
return ""
|
||||
}
|
||||
path := r.URL.EscapedPath()
|
||||
if path == "" && r.URL.Path != "" {
|
||||
path = r.URL.Path
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func remoteIP(r *http.Request) string {
|
||||
if r.RemoteAddr == "" {
|
||||
return ""
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
// EnqueueWithContext queues a message and applies any PostHog request context attached to ctx.
|
||||
// Use this from HTTP handlers with r.Context() when NewRequestContextMiddleware is installed.
|
||||
func EnqueueWithContext(ctx context.Context, client EnqueueClient, msg Message) error {
|
||||
return enqueueWithContext(ctx, client, msg)
|
||||
}
|
||||
|
||||
// EvaluateFlagsWithContext evaluates feature flags and uses the request-scoped distinct ID attached
|
||||
// to ctx when payload.DistinctId is empty. It does not generate personless IDs: feature flag
|
||||
// evaluation requires a stable distinct ID and returns ErrNoDistinctID if neither payload nor ctx has one.
|
||||
func EvaluateFlagsWithContext(ctx context.Context, client Client, payload EvaluateFlagsPayload) (*FeatureFlagEvaluations, error) {
|
||||
if client == nil {
|
||||
return noopFeatureFlagEvaluations, fmt.Errorf("posthog: nil client")
|
||||
}
|
||||
if contextClient, ok := client.(interface {
|
||||
EvaluateFlagsWithContext(context.Context, EvaluateFlagsPayload) (*FeatureFlagEvaluations, error)
|
||||
}); ok {
|
||||
return contextClient.EvaluateFlagsWithContext(ctx, payload)
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if payload.DistinctId == "" {
|
||||
if requestContext, ok := RequestContextFromContext(ctx); ok {
|
||||
payload.DistinctId = requestContext.DistinctId
|
||||
}
|
||||
}
|
||||
return client.EvaluateFlags(payload)
|
||||
}
|
||||
|
||||
func enqueueWithContext(ctx context.Context, client EnqueueClient, msg Message) error {
|
||||
if client == nil {
|
||||
return fmt.Errorf("posthog: nil enqueue client")
|
||||
}
|
||||
if contextClient, ok := client.(interface {
|
||||
EnqueueWithContext(context.Context, Message) error
|
||||
}); ok {
|
||||
return contextClient.EnqueueWithContext(ctx, msg)
|
||||
}
|
||||
return client.Enqueue(msg)
|
||||
}
|
||||
|
||||
func capturePanic(ctx context.Context, client EnqueueClient, panicValue interface{}, statusCode int, panicStack []byte) {
|
||||
defer func() {
|
||||
// Panic capture runs while the host application is already panicking. Any SDK,
|
||||
// callback, or client panic here must not replace the original application panic.
|
||||
_ = recover()
|
||||
}()
|
||||
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
if statusCode < http.StatusBadRequest {
|
||||
statusCode = http.StatusInternalServerError
|
||||
}
|
||||
|
||||
exception := Exception{
|
||||
Properties: NewProperties().
|
||||
Set(propertyResponseStatusCode, statusCode).
|
||||
Set(propertyExceptionSource, "panic"),
|
||||
ExceptionList: []ExceptionItem{{
|
||||
Type: "panic",
|
||||
Value: fmt.Sprint(panicValue),
|
||||
Stacktrace: stackTraceFromDebugStack(panicStack),
|
||||
}},
|
||||
}
|
||||
|
||||
_ = enqueueWithContext(ctx, client, exception)
|
||||
}
|
||||
|
||||
func stackTraceFromDebugStack(stack []byte) *ExceptionStacktrace {
|
||||
frames := parseDebugStackFrames(stack)
|
||||
if len(frames) == 0 {
|
||||
return DefaultStackTraceExtractor{InAppDecider: SimpleInAppDecider}.GetStackTrace(4)
|
||||
}
|
||||
|
||||
return &ExceptionStacktrace{
|
||||
Type: "raw",
|
||||
Frames: frames,
|
||||
}
|
||||
}
|
||||
|
||||
func parseDebugStackFrames(stack []byte) []StackFrame {
|
||||
lines := strings.Split(string(stack), "\n")
|
||||
frames := make([]StackFrame, 0, len(lines)/2)
|
||||
include := false
|
||||
|
||||
for i := 1; i+1 < len(lines); i += 2 {
|
||||
function := strings.TrimSpace(lines[i])
|
||||
if function == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(function, "panic(") {
|
||||
include = true
|
||||
continue
|
||||
}
|
||||
if !include {
|
||||
continue
|
||||
}
|
||||
|
||||
filename, lineNo, ok := parseDebugStackFileLine(lines[i+1])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
frames = append(frames, StackFrame{
|
||||
Filename: filename,
|
||||
LineNo: lineNo,
|
||||
Function: function,
|
||||
InApp: SimpleInAppDecider(runtime.Frame{File: filename, Function: function}),
|
||||
Synthetic: false,
|
||||
Platform: "go",
|
||||
})
|
||||
}
|
||||
|
||||
return frames
|
||||
}
|
||||
|
||||
func parseDebugStackFileLine(line string) (string, int, bool) {
|
||||
fileLine := strings.TrimSpace(line)
|
||||
if fileLine == "" {
|
||||
return "", 0, false
|
||||
}
|
||||
if idx := strings.Index(fileLine, " +"); idx >= 0 {
|
||||
fileLine = fileLine[:idx]
|
||||
}
|
||||
colon := strings.LastIndex(fileLine, ":")
|
||||
if colon <= 0 || colon == len(fileLine)-1 {
|
||||
return "", 0, false
|
||||
}
|
||||
lineNo, err := strconv.Atoi(fileLine[colon+1:])
|
||||
if err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
return fileLine[:colon], lineNo, true
|
||||
}
|
||||
|
||||
type responseStatusWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
wroteHeader bool
|
||||
flusher http.Flusher
|
||||
hijacker http.Hijacker
|
||||
pusher http.Pusher
|
||||
readerFrom io.ReaderFrom
|
||||
}
|
||||
|
||||
func newResponseStatusWriter(w http.ResponseWriter) *responseStatusWriter {
|
||||
statusWriter := &responseStatusWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
statusWriter.flusher = flusher
|
||||
}
|
||||
if hijacker, ok := w.(http.Hijacker); ok {
|
||||
statusWriter.hijacker = hijacker
|
||||
}
|
||||
if pusher, ok := w.(http.Pusher); ok {
|
||||
statusWriter.pusher = pusher
|
||||
}
|
||||
if readerFrom, ok := w.(io.ReaderFrom); ok {
|
||||
statusWriter.readerFrom = readerFrom
|
||||
}
|
||||
return statusWriter
|
||||
}
|
||||
|
||||
func (w *responseStatusWriter) Unwrap() http.ResponseWriter {
|
||||
return w.ResponseWriter
|
||||
}
|
||||
|
||||
func (w *responseStatusWriter) WriteHeader(statusCode int) {
|
||||
if !w.wroteHeader {
|
||||
w.statusCode = statusCode
|
||||
w.wroteHeader = true
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
func (w *responseStatusWriter) Write(data []byte) (int, error) {
|
||||
if !w.wroteHeader {
|
||||
w.statusCode = http.StatusOK
|
||||
w.wroteHeader = true
|
||||
}
|
||||
return w.ResponseWriter.Write(data)
|
||||
}
|
||||
|
||||
const (
|
||||
responseStatusFlusherFlag = 1 << iota
|
||||
responseStatusHijackerFlag
|
||||
responseStatusPusherFlag
|
||||
responseStatusReaderFromFlag
|
||||
)
|
||||
|
||||
func wrapResponseStatusWriter(w *responseStatusWriter) http.ResponseWriter {
|
||||
flags := 0
|
||||
if w.flusher != nil {
|
||||
flags |= responseStatusFlusherFlag
|
||||
}
|
||||
if w.hijacker != nil {
|
||||
flags |= responseStatusHijackerFlag
|
||||
}
|
||||
if w.pusher != nil {
|
||||
flags |= responseStatusPusherFlag
|
||||
}
|
||||
if w.readerFrom != nil {
|
||||
flags |= responseStatusReaderFromFlag
|
||||
}
|
||||
|
||||
switch flags {
|
||||
case responseStatusFlusherFlag:
|
||||
return &responseStatusWriterFlusher{w}
|
||||
case responseStatusHijackerFlag:
|
||||
return &responseStatusWriterHijacker{w}
|
||||
case responseStatusPusherFlag:
|
||||
return &responseStatusWriterPusher{w}
|
||||
case responseStatusReaderFromFlag:
|
||||
return &responseStatusWriterReaderFrom{w}
|
||||
case responseStatusFlusherFlag | responseStatusHijackerFlag:
|
||||
return &responseStatusWriterFlusherHijacker{w}
|
||||
case responseStatusFlusherFlag | responseStatusPusherFlag:
|
||||
return &responseStatusWriterFlusherPusher{w}
|
||||
case responseStatusFlusherFlag | responseStatusReaderFromFlag:
|
||||
return &responseStatusWriterFlusherReaderFrom{w}
|
||||
case responseStatusHijackerFlag | responseStatusPusherFlag:
|
||||
return &responseStatusWriterHijackerPusher{w}
|
||||
case responseStatusHijackerFlag | responseStatusReaderFromFlag:
|
||||
return &responseStatusWriterHijackerReaderFrom{w}
|
||||
case responseStatusPusherFlag | responseStatusReaderFromFlag:
|
||||
return &responseStatusWriterPusherReaderFrom{w}
|
||||
case responseStatusFlusherFlag | responseStatusHijackerFlag | responseStatusPusherFlag:
|
||||
return &responseStatusWriterFlusherHijackerPusher{w}
|
||||
case responseStatusFlusherFlag | responseStatusHijackerFlag | responseStatusReaderFromFlag:
|
||||
return &responseStatusWriterFlusherHijackerReaderFrom{w}
|
||||
case responseStatusFlusherFlag | responseStatusPusherFlag | responseStatusReaderFromFlag:
|
||||
return &responseStatusWriterFlusherPusherReaderFrom{w}
|
||||
case responseStatusHijackerFlag | responseStatusPusherFlag | responseStatusReaderFromFlag:
|
||||
return &responseStatusWriterHijackerPusherReaderFrom{w}
|
||||
case responseStatusFlusherFlag | responseStatusHijackerFlag | responseStatusPusherFlag | responseStatusReaderFromFlag:
|
||||
return &responseStatusWriterFlusherHijackerPusherReaderFrom{w}
|
||||
default:
|
||||
return w
|
||||
}
|
||||
}
|
||||
|
||||
type responseStatusWriterFlusher struct{ *responseStatusWriter }
|
||||
|
||||
func (w *responseStatusWriterFlusher) Flush() { w.flusher.Flush() }
|
||||
|
||||
type responseStatusWriterHijacker struct{ *responseStatusWriter }
|
||||
|
||||
func (w *responseStatusWriterHijacker) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
return w.hijacker.Hijack()
|
||||
}
|
||||
|
||||
type responseStatusWriterPusher struct{ *responseStatusWriter }
|
||||
|
||||
func (w *responseStatusWriterPusher) Push(target string, opts *http.PushOptions) error {
|
||||
return w.pusher.Push(target, opts)
|
||||
}
|
||||
|
||||
type responseStatusWriterReaderFrom struct{ *responseStatusWriter }
|
||||
|
||||
func (w *responseStatusWriterReaderFrom) ReadFrom(reader io.Reader) (int64, error) {
|
||||
return w.readerFrom.ReadFrom(reader)
|
||||
}
|
||||
|
||||
type responseStatusWriterFlusherHijacker struct{ *responseStatusWriter }
|
||||
|
||||
func (w *responseStatusWriterFlusherHijacker) Flush() { w.flusher.Flush() }
|
||||
func (w *responseStatusWriterFlusherHijacker) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
return w.hijacker.Hijack()
|
||||
}
|
||||
|
||||
type responseStatusWriterFlusherPusher struct{ *responseStatusWriter }
|
||||
|
||||
func (w *responseStatusWriterFlusherPusher) Flush() { w.flusher.Flush() }
|
||||
func (w *responseStatusWriterFlusherPusher) Push(target string, opts *http.PushOptions) error {
|
||||
return w.pusher.Push(target, opts)
|
||||
}
|
||||
|
||||
type responseStatusWriterFlusherReaderFrom struct{ *responseStatusWriter }
|
||||
|
||||
func (w *responseStatusWriterFlusherReaderFrom) Flush() { w.flusher.Flush() }
|
||||
func (w *responseStatusWriterFlusherReaderFrom) ReadFrom(reader io.Reader) (int64, error) {
|
||||
return w.readerFrom.ReadFrom(reader)
|
||||
}
|
||||
|
||||
type responseStatusWriterHijackerPusher struct{ *responseStatusWriter }
|
||||
|
||||
func (w *responseStatusWriterHijackerPusher) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
return w.hijacker.Hijack()
|
||||
}
|
||||
func (w *responseStatusWriterHijackerPusher) Push(target string, opts *http.PushOptions) error {
|
||||
return w.pusher.Push(target, opts)
|
||||
}
|
||||
|
||||
type responseStatusWriterHijackerReaderFrom struct{ *responseStatusWriter }
|
||||
|
||||
func (w *responseStatusWriterHijackerReaderFrom) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
return w.hijacker.Hijack()
|
||||
}
|
||||
func (w *responseStatusWriterHijackerReaderFrom) ReadFrom(reader io.Reader) (int64, error) {
|
||||
return w.readerFrom.ReadFrom(reader)
|
||||
}
|
||||
|
||||
type responseStatusWriterPusherReaderFrom struct{ *responseStatusWriter }
|
||||
|
||||
func (w *responseStatusWriterPusherReaderFrom) Push(target string, opts *http.PushOptions) error {
|
||||
return w.pusher.Push(target, opts)
|
||||
}
|
||||
func (w *responseStatusWriterPusherReaderFrom) ReadFrom(reader io.Reader) (int64, error) {
|
||||
return w.readerFrom.ReadFrom(reader)
|
||||
}
|
||||
|
||||
type responseStatusWriterFlusherHijackerPusher struct{ *responseStatusWriter }
|
||||
|
||||
func (w *responseStatusWriterFlusherHijackerPusher) Flush() { w.flusher.Flush() }
|
||||
func (w *responseStatusWriterFlusherHijackerPusher) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
return w.hijacker.Hijack()
|
||||
}
|
||||
func (w *responseStatusWriterFlusherHijackerPusher) Push(target string, opts *http.PushOptions) error {
|
||||
return w.pusher.Push(target, opts)
|
||||
}
|
||||
|
||||
type responseStatusWriterFlusherHijackerReaderFrom struct{ *responseStatusWriter }
|
||||
|
||||
func (w *responseStatusWriterFlusherHijackerReaderFrom) Flush() { w.flusher.Flush() }
|
||||
func (w *responseStatusWriterFlusherHijackerReaderFrom) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
return w.hijacker.Hijack()
|
||||
}
|
||||
func (w *responseStatusWriterFlusherHijackerReaderFrom) ReadFrom(reader io.Reader) (int64, error) {
|
||||
return w.readerFrom.ReadFrom(reader)
|
||||
}
|
||||
|
||||
type responseStatusWriterFlusherPusherReaderFrom struct{ *responseStatusWriter }
|
||||
|
||||
func (w *responseStatusWriterFlusherPusherReaderFrom) Flush() { w.flusher.Flush() }
|
||||
func (w *responseStatusWriterFlusherPusherReaderFrom) Push(target string, opts *http.PushOptions) error {
|
||||
return w.pusher.Push(target, opts)
|
||||
}
|
||||
func (w *responseStatusWriterFlusherPusherReaderFrom) ReadFrom(reader io.Reader) (int64, error) {
|
||||
return w.readerFrom.ReadFrom(reader)
|
||||
}
|
||||
|
||||
type responseStatusWriterHijackerPusherReaderFrom struct{ *responseStatusWriter }
|
||||
|
||||
func (w *responseStatusWriterHijackerPusherReaderFrom) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
return w.hijacker.Hijack()
|
||||
}
|
||||
func (w *responseStatusWriterHijackerPusherReaderFrom) Push(target string, opts *http.PushOptions) error {
|
||||
return w.pusher.Push(target, opts)
|
||||
}
|
||||
func (w *responseStatusWriterHijackerPusherReaderFrom) ReadFrom(reader io.Reader) (int64, error) {
|
||||
return w.readerFrom.ReadFrom(reader)
|
||||
}
|
||||
|
||||
type responseStatusWriterFlusherHijackerPusherReaderFrom struct{ *responseStatusWriter }
|
||||
|
||||
func (w *responseStatusWriterFlusherHijackerPusherReaderFrom) Flush() { w.flusher.Flush() }
|
||||
func (w *responseStatusWriterFlusherHijackerPusherReaderFrom) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
return w.hijacker.Hijack()
|
||||
}
|
||||
func (w *responseStatusWriterFlusherHijackerPusherReaderFrom) Push(target string, opts *http.PushOptions) error {
|
||||
return w.pusher.Push(target, opts)
|
||||
}
|
||||
func (w *responseStatusWriterFlusherHijackerPusherReaderFrom) ReadFrom(reader io.Reader) (int64, error) {
|
||||
return w.readerFrom.ReadFrom(reader)
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
package posthog
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEnqueueWithContext_AppliesRequestContextHeadersAndMetadata(t *testing.T) {
|
||||
body, server := mockServer()
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-key", Config{Endpoint: server.URL, BatchSize: 1, now: mockTime})
|
||||
defer client.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "https://example.com/api/test?oauth_code=secret", nil)
|
||||
req.RemoteAddr = "10.0.0.2:4567"
|
||||
req.Header.Set("x-posthog-distinct-id", " frontend-user ")
|
||||
req.Header.Set("x-posthog-session-id", " frontend-session ")
|
||||
req.Header.Set("User-Agent", "TestAgent/1.0")
|
||||
ctx := WithFreshRequestContext(context.Background(), ExtractRequestContext(req, true))
|
||||
|
||||
err := EnqueueWithContext(ctx, client, Capture{Event: "request-context-event"})
|
||||
require.NoError(t, err)
|
||||
|
||||
event := readSingleBatchEvent(t, body)
|
||||
require.Equal(t, "frontend-user", event["distinct_id"])
|
||||
properties := requireProperties(t, event)
|
||||
require.Equal(t, "frontend-session", properties[propertySessionID])
|
||||
require.Equal(t, "https://example.com/api/test", properties[propertyCurrentURL])
|
||||
require.Equal(t, http.MethodPost, properties[propertyRequestMethod])
|
||||
require.Equal(t, "/api/test", properties[propertyRequestPath])
|
||||
require.Equal(t, "TestAgent/1.0", properties[propertyUserAgent])
|
||||
require.Equal(t, "10.0.0.2", properties[propertyIP])
|
||||
require.NotContains(t, properties, propertyProcessPersonProfile)
|
||||
}
|
||||
|
||||
func TestEnqueueWithContext_ExplicitCaptureValuesOverrideContext(t *testing.T) {
|
||||
body, server := mockServer()
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-key", Config{Endpoint: server.URL, BatchSize: 1, now: mockTime})
|
||||
defer client.Close()
|
||||
|
||||
ctx := WithFreshRequestContext(context.Background(), RequestContext{
|
||||
DistinctId: "context-user",
|
||||
SessionId: "context-session",
|
||||
Properties: NewProperties().Set("shared", "context-value").Set("context-only", "context-only-value"),
|
||||
})
|
||||
|
||||
err := EnqueueWithContext(ctx, client, Capture{
|
||||
DistinctId: "explicit-user",
|
||||
Event: "explicit-event",
|
||||
Properties: NewProperties().Set("shared", "explicit-value").Set(propertySessionID, "explicit-session"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
event := readSingleBatchEvent(t, body)
|
||||
require.Equal(t, "explicit-user", event["distinct_id"])
|
||||
properties := requireProperties(t, event)
|
||||
require.Equal(t, "explicit-session", properties[propertySessionID])
|
||||
require.Equal(t, "explicit-value", properties["shared"])
|
||||
require.Equal(t, "context-only-value", properties["context-only"])
|
||||
require.NotContains(t, properties, propertyProcessPersonProfile)
|
||||
}
|
||||
|
||||
func TestEnqueue_MissingIdentityWithoutRequestContextReturnsDistinctIdError(t *testing.T) {
|
||||
client, _ := NewWithConfig("test-key", Config{Transport: NoOpTransport()})
|
||||
defer client.Close()
|
||||
|
||||
err := client.Enqueue(Capture{Event: "personless-event"})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "DistinctId")
|
||||
}
|
||||
|
||||
func TestEnqueueWithContext_MissingIdentityWithRequestContextCreatesPersonlessCapture(t *testing.T) {
|
||||
body, server := mockServer()
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-key", Config{Endpoint: server.URL, BatchSize: 1, now: mockTime})
|
||||
defer client.Close()
|
||||
|
||||
ctx := WithFreshRequestContext(context.Background(), RequestContext{})
|
||||
err := EnqueueWithContext(ctx, client, Capture{Event: "personless-event"})
|
||||
require.NoError(t, err)
|
||||
|
||||
event := readSingleBatchEvent(t, body)
|
||||
distinctID, ok := event["distinct_id"].(string)
|
||||
require.True(t, ok)
|
||||
require.NoError(t, uuid.Validate(distinctID))
|
||||
properties := requireProperties(t, event)
|
||||
require.Equal(t, false, properties[propertyProcessPersonProfile])
|
||||
}
|
||||
|
||||
func TestEnqueueWithContext_PersonlessCapturePreservesExplicitProcessPersonProfile(t *testing.T) {
|
||||
body, server := mockServer()
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-key", Config{Endpoint: server.URL, BatchSize: 1, now: mockTime})
|
||||
defer client.Close()
|
||||
|
||||
ctx := WithFreshRequestContext(context.Background(), RequestContext{})
|
||||
err := EnqueueWithContext(ctx, client, Capture{
|
||||
Event: "personless-event",
|
||||
Properties: NewProperties().Set(propertyProcessPersonProfile, true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
event := readSingleBatchEvent(t, body)
|
||||
properties := requireProperties(t, event)
|
||||
require.Equal(t, true, properties[propertyProcessPersonProfile])
|
||||
}
|
||||
|
||||
func TestEnqueueWithContext_PersonlessCaptureSkipsSendFeatureFlags(t *testing.T) {
|
||||
body, server := mockServer()
|
||||
defer server.Close()
|
||||
|
||||
logger := &captureLogger{}
|
||||
client, _ := NewWithConfig("test-key", Config{Endpoint: server.URL, BatchSize: 1, now: mockTime, Logger: logger})
|
||||
defer client.Close()
|
||||
|
||||
ctx := WithFreshRequestContext(context.Background(), RequestContext{})
|
||||
err := EnqueueWithContext(ctx, client, Capture{
|
||||
Event: "personless-event",
|
||||
SendFeatureFlags: SendFeatureFlags(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
event := readSingleBatchEvent(t, body)
|
||||
properties := requireProperties(t, event)
|
||||
require.NotContains(t, properties, "$active_feature_flags")
|
||||
for key := range properties {
|
||||
require.False(t, strings.HasPrefix(key, "$feature/"), "personless capture should not attach feature flag properties")
|
||||
}
|
||||
for _, warning := range logger.snapshot() {
|
||||
require.NotContains(t, warning, "Capture.SendFeatureFlags")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnqueueWithContext_ExceptionUsesContextAndExplicitIdentity(t *testing.T) {
|
||||
body, server := mockServer()
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-key", Config{Endpoint: server.URL, BatchSize: 1, now: mockTime})
|
||||
defer client.Close()
|
||||
|
||||
ctx := WithFreshRequestContext(context.Background(), RequestContext{
|
||||
DistinctId: "context-user",
|
||||
SessionId: "context-session",
|
||||
Properties: NewProperties().Set("request", "metadata"),
|
||||
})
|
||||
|
||||
err := EnqueueWithContext(ctx, client, Exception{
|
||||
DistinctId: "explicit-user",
|
||||
ExceptionList: []ExceptionItem{{
|
||||
Type: "Error",
|
||||
Value: "boom",
|
||||
}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
event := readSingleBatchEvent(t, body)
|
||||
require.Equal(t, "$exception", event["event"])
|
||||
properties := requireProperties(t, event)
|
||||
require.Equal(t, "explicit-user", properties["distinct_id"])
|
||||
require.Equal(t, "context-session", properties[propertySessionID])
|
||||
require.Equal(t, "metadata", properties["request"])
|
||||
require.NotContains(t, properties, propertyProcessPersonProfile)
|
||||
}
|
||||
|
||||
func TestEnqueueWithContext_ExceptionFallsBackToRequestContext(t *testing.T) {
|
||||
body, server := mockServer()
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-key", Config{Endpoint: server.URL, BatchSize: 1, now: mockTime})
|
||||
defer client.Close()
|
||||
|
||||
ctx := WithFreshRequestContext(context.Background(), RequestContext{
|
||||
DistinctId: "context-user",
|
||||
SessionId: "context-session",
|
||||
})
|
||||
|
||||
err := EnqueueWithContext(ctx, client, Exception{ExceptionList: []ExceptionItem{{Type: "Error", Value: "boom"}}})
|
||||
require.NoError(t, err)
|
||||
|
||||
event := readSingleBatchEvent(t, body)
|
||||
properties := requireProperties(t, event)
|
||||
require.Equal(t, "context-user", properties["distinct_id"])
|
||||
require.Equal(t, "context-session", properties[propertySessionID])
|
||||
require.NotContains(t, properties, propertyProcessPersonProfile)
|
||||
}
|
||||
|
||||
func TestEnqueue_ExceptionMissingIdentityWithoutRequestContextReturnsDistinctIdError(t *testing.T) {
|
||||
client, _ := NewWithConfig("test-key", Config{Transport: NoOpTransport()})
|
||||
defer client.Close()
|
||||
|
||||
err := client.Enqueue(Exception{ExceptionList: []ExceptionItem{{Type: "Error", Value: "boom"}}})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "DistinctId")
|
||||
}
|
||||
|
||||
func TestEnqueueWithContext_ExceptionMissingIdentityWithRequestContextCreatesPersonlessEvent(t *testing.T) {
|
||||
body, server := mockServer()
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-key", Config{Endpoint: server.URL, BatchSize: 1, now: mockTime})
|
||||
defer client.Close()
|
||||
|
||||
ctx := WithFreshRequestContext(context.Background(), RequestContext{})
|
||||
err := EnqueueWithContext(ctx, client, Exception{ExceptionList: []ExceptionItem{{Type: "Error", Value: "boom"}}})
|
||||
require.NoError(t, err)
|
||||
|
||||
event := readSingleBatchEvent(t, body)
|
||||
properties := requireProperties(t, event)
|
||||
distinctID, ok := properties["distinct_id"].(string)
|
||||
require.True(t, ok)
|
||||
require.NoError(t, uuid.Validate(distinctID))
|
||||
require.Equal(t, false, properties[propertyProcessPersonProfile])
|
||||
}
|
||||
|
||||
func TestRequestContextMiddleware_DisableTracingHeadersPreservesMetadata(t *testing.T) {
|
||||
body, server := mockServer()
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-key", Config{Endpoint: server.URL, BatchSize: 1, now: mockTime})
|
||||
defer client.Close()
|
||||
|
||||
handler := NewRequestContextMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
requestContext, ok := RequestContextFromContext(r.Context())
|
||||
require.True(t, ok)
|
||||
require.Empty(t, requestContext.DistinctId)
|
||||
require.Empty(t, requestContext.SessionId)
|
||||
require.NoError(t, EnqueueWithContext(r.Context(), client, Capture{Event: "metadata-event"}))
|
||||
}), WithCaptureTracingHeaders(false))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "https://example.com/api/test?token=secret", nil)
|
||||
req.RemoteAddr = "10.0.0.2:1234"
|
||||
req.Header.Set(HeaderPostHogDistinctID, "header-user")
|
||||
req.Header.Set(HeaderPostHogSessionID, "header-session")
|
||||
req.Header.Set("User-Agent", "TestAgent/1.0")
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
event := readSingleBatchEvent(t, body)
|
||||
distinctID, ok := event["distinct_id"].(string)
|
||||
require.True(t, ok)
|
||||
require.NotEqual(t, "header-user", distinctID)
|
||||
require.NoError(t, uuid.Validate(distinctID))
|
||||
properties := requireProperties(t, event)
|
||||
require.Equal(t, false, properties[propertyProcessPersonProfile])
|
||||
require.NotContains(t, properties, propertySessionID)
|
||||
require.Equal(t, "/api/test", properties[propertyRequestPath])
|
||||
require.Equal(t, "TestAgent/1.0", properties[propertyUserAgent])
|
||||
require.Equal(t, "10.0.0.2", properties[propertyIP])
|
||||
}
|
||||
|
||||
func TestRequestContextMiddleware_SanitizesHeaders(t *testing.T) {
|
||||
body, server := mockServer()
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-key", Config{Endpoint: server.URL, BatchSize: 1, now: mockTime})
|
||||
defer client.Close()
|
||||
|
||||
handler := NewRequestContextMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
require.NoError(t, EnqueueWithContext(r.Context(), client, Capture{Event: "sanitized-event"}))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "https://example.com/api/test", nil)
|
||||
req.Header.Set(HeaderPostHogDistinctID, " \u0000\u0001\u0002 ")
|
||||
req.Header.Set(HeaderPostHogSessionID, " "+strings.Repeat("s", 1200)+"\u0000 ")
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
event := readSingleBatchEvent(t, body)
|
||||
distinctID, ok := event["distinct_id"].(string)
|
||||
require.True(t, ok)
|
||||
require.NoError(t, uuid.Validate(distinctID))
|
||||
properties := requireProperties(t, event)
|
||||
require.Equal(t, strings.Repeat("s", maxRequestContextValueLength), properties[propertySessionID])
|
||||
}
|
||||
|
||||
func TestRequestContextMiddleware_ConcurrentRequestsDoNotLeak(t *testing.T) {
|
||||
results := map[string]RequestContext{}
|
||||
var mu sync.Mutex
|
||||
|
||||
handler := NewRequestContextMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
requestContext, ok := RequestContextFromContext(r.Context())
|
||||
require.True(t, ok)
|
||||
mu.Lock()
|
||||
results[r.URL.Path] = requestContext
|
||||
mu.Unlock()
|
||||
}))
|
||||
|
||||
first := httptest.NewRequest(http.MethodGet, "https://example.com/first", nil)
|
||||
first.Header.Set(HeaderPostHogDistinctID, "user-a")
|
||||
first.Header.Set(HeaderPostHogSessionID, "session-a")
|
||||
second := httptest.NewRequest(http.MethodGet, "https://example.com/second", nil)
|
||||
second.Header.Set(HeaderPostHogDistinctID, "user-b")
|
||||
second.Header.Set(HeaderPostHogSessionID, "session-b")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
handler.ServeHTTP(httptest.NewRecorder(), first)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
handler.ServeHTTP(httptest.NewRecorder(), second)
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
require.Equal(t, "user-a", results["/first"].DistinctId)
|
||||
require.Equal(t, "session-a", results["/first"].SessionId)
|
||||
require.Equal(t, "user-b", results["/second"].DistinctId)
|
||||
require.Equal(t, "session-b", results["/second"].SessionId)
|
||||
}
|
||||
|
||||
func TestEnqueueWithContext_PersonlessCaptureDefaultPropertiesCannotEnablePersonProfiles(t *testing.T) {
|
||||
body, server := mockServer()
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
BatchSize: 1,
|
||||
now: mockTime,
|
||||
DefaultEventProperties: NewProperties().Set(propertyProcessPersonProfile, true).Set("service", "api"),
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
ctx := WithFreshRequestContext(context.Background(), RequestContext{})
|
||||
err := EnqueueWithContext(ctx, client, Capture{Event: "personless-event"})
|
||||
require.NoError(t, err)
|
||||
|
||||
event := readSingleBatchEvent(t, body)
|
||||
properties := requireProperties(t, event)
|
||||
require.Equal(t, false, properties[propertyProcessPersonProfile])
|
||||
require.Equal(t, "api", properties["service"])
|
||||
}
|
||||
|
||||
func TestRequestContextMiddleware_CapturesPanicsWithRequestContextAndRethrows(t *testing.T) {
|
||||
body, server := mockServer()
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-key", Config{Endpoint: server.URL, BatchSize: 1, now: mockTime})
|
||||
defer client.Close()
|
||||
|
||||
handler := NewRequestContextMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
panic(errors.New("boom"))
|
||||
}), WithCapturePanics(client))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "https://example.com/api/test", nil)
|
||||
req.RemoteAddr = "10.0.0.2:1234"
|
||||
req.Header.Set(HeaderPostHogDistinctID, "client-user")
|
||||
req.Header.Set(HeaderPostHogSessionID, "client-session")
|
||||
require.Panics(t, func() { handler.ServeHTTP(httptest.NewRecorder(), req) })
|
||||
|
||||
event := readSingleBatchEvent(t, body)
|
||||
require.Equal(t, "$exception", event["event"])
|
||||
properties := requireProperties(t, event)
|
||||
require.Equal(t, "client-user", properties["distinct_id"])
|
||||
require.Equal(t, "client-session", properties[propertySessionID])
|
||||
require.Equal(t, float64(http.StatusServiceUnavailable), properties[propertyResponseStatusCode])
|
||||
require.Equal(t, "panic", properties[propertyExceptionSource])
|
||||
requireExceptionStackContainsFunction(t, properties, "TestRequestContextMiddleware_CapturesPanicsWithRequestContextAndRethrows")
|
||||
}
|
||||
|
||||
func TestRequestContextMiddleware_PanicCaptureFailuresDoNotReplaceOriginalPanic(t *testing.T) {
|
||||
originalPanic := errors.New("original application panic")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
opts []RequestContextOption
|
||||
}{
|
||||
{
|
||||
name: "enqueue panics",
|
||||
opts: []RequestContextOption{WithCapturePanics(panickingEnqueueClient{})},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
handler := NewRequestContextMiddleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
panic(originalPanic)
|
||||
}), tt.opts...)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "https://example.com/panic", nil)
|
||||
require.PanicsWithValue(t, originalPanic, func() {
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestContextMiddleware_PanicCaptureUsesFirstResponseStatus(t *testing.T) {
|
||||
body, server := mockServer()
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-key", Config{Endpoint: server.URL, BatchSize: 1, now: mockTime})
|
||||
defer client.Close()
|
||||
|
||||
handler := NewRequestContextMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
panic(errors.New("boom"))
|
||||
}), WithCapturePanics(client))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "https://example.com/panic", nil)
|
||||
require.Panics(t, func() { handler.ServeHTTP(httptest.NewRecorder(), req) })
|
||||
|
||||
event := readSingleBatchEvent(t, body)
|
||||
properties := requireProperties(t, event)
|
||||
require.Equal(t, float64(http.StatusTooManyRequests), properties[propertyResponseStatusCode])
|
||||
}
|
||||
|
||||
func TestResponseStatusWriterTracksFirstStatusAndUnwraps(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
writer := &responseStatusWriter{ResponseWriter: recorder, statusCode: http.StatusOK}
|
||||
|
||||
require.Same(t, recorder, writer.Unwrap())
|
||||
writer.WriteHeader(http.StatusAccepted)
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
require.Equal(t, http.StatusAccepted, writer.statusCode)
|
||||
}
|
||||
|
||||
func TestRequestContextMiddleware_PanicWriterDoesNotAdvertiseUnsupportedOptionalInterfaces(t *testing.T) {
|
||||
var report optionalInterfaceReport
|
||||
handler := NewRequestContextMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
report = captureOptionalInterfaceReport(w)
|
||||
}), WithCapturePanics(&noopClient{}))
|
||||
|
||||
handler.ServeHTTP(newMinimalResponseWriter(), httptest.NewRequest(http.MethodGet, "https://example.com", nil))
|
||||
|
||||
require.False(t, report.flusher)
|
||||
require.False(t, report.hijacker)
|
||||
require.False(t, report.pusher)
|
||||
require.False(t, report.readerFrom)
|
||||
}
|
||||
|
||||
func TestRequestContextMiddleware_PanicWriterPreservesSupportedOptionalInterfaces(t *testing.T) {
|
||||
writer := &allOptionalResponseWriter{minimalResponseWriter: newMinimalResponseWriter()}
|
||||
var report optionalInterfaceReport
|
||||
handler := NewRequestContextMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
report = captureOptionalInterfaceReport(w)
|
||||
w.(http.Flusher).Flush()
|
||||
_, _, _ = w.(http.Hijacker).Hijack()
|
||||
require.NoError(t, w.(http.Pusher).Push("/asset.css", nil))
|
||||
_, err := w.(io.ReaderFrom).ReadFrom(strings.NewReader("body"))
|
||||
require.NoError(t, err)
|
||||
}), WithCapturePanics(&noopClient{}))
|
||||
|
||||
handler.ServeHTTP(writer, httptest.NewRequest(http.MethodGet, "https://example.com", nil))
|
||||
|
||||
require.True(t, report.flusher)
|
||||
require.True(t, report.hijacker)
|
||||
require.True(t, report.pusher)
|
||||
require.True(t, report.readerFrom)
|
||||
require.True(t, writer.flushed)
|
||||
require.True(t, writer.hijacked)
|
||||
require.Equal(t, "/asset.css", writer.pushedTarget)
|
||||
require.True(t, writer.readFromCalled)
|
||||
}
|
||||
|
||||
type panickingEnqueueClient struct{}
|
||||
|
||||
func (panickingEnqueueClient) Enqueue(Message) error {
|
||||
panic("enqueue panic")
|
||||
}
|
||||
|
||||
func (panickingEnqueueClient) EnqueueWithContext(context.Context, Message) error {
|
||||
panic("enqueue with context panic")
|
||||
}
|
||||
|
||||
func readSingleBatchEvent(t *testing.T, body <-chan []byte) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case payload := <-body:
|
||||
var decoded map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(payload, &decoded))
|
||||
batch, ok := decoded["batch"].([]interface{})
|
||||
require.True(t, ok)
|
||||
require.Len(t, batch, 1)
|
||||
event, ok := batch[0].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
return event
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timeout waiting for batch payload")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func requireProperties(t *testing.T, event map[string]interface{}) map[string]interface{} {
|
||||
t.Helper()
|
||||
properties, ok := event["properties"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
return properties
|
||||
}
|
||||
|
||||
func requireExceptionStackContainsFunction(t *testing.T, properties map[string]interface{}, functionName string) {
|
||||
t.Helper()
|
||||
exceptionList, ok := properties["$exception_list"].([]interface{})
|
||||
require.True(t, ok)
|
||||
require.NotEmpty(t, exceptionList)
|
||||
exceptionItem, ok := exceptionList[0].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
stacktrace, ok := exceptionItem["stacktrace"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
frames, ok := stacktrace["frames"].([]interface{})
|
||||
require.True(t, ok)
|
||||
for _, rawFrame := range frames {
|
||||
frame, ok := rawFrame.(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
function, _ := frame["function"].(string)
|
||||
if strings.Contains(function, functionName) {
|
||||
return
|
||||
}
|
||||
}
|
||||
require.Failf(t, "missing panic frame", "expected stacktrace to contain function %q, got %#v", functionName, frames)
|
||||
}
|
||||
|
||||
type optionalInterfaceReport struct {
|
||||
flusher bool
|
||||
hijacker bool
|
||||
pusher bool
|
||||
readerFrom bool
|
||||
}
|
||||
|
||||
func captureOptionalInterfaceReport(w http.ResponseWriter) optionalInterfaceReport {
|
||||
_, flusher := w.(http.Flusher)
|
||||
_, hijacker := w.(http.Hijacker)
|
||||
_, pusher := w.(http.Pusher)
|
||||
_, readerFrom := w.(io.ReaderFrom)
|
||||
return optionalInterfaceReport{
|
||||
flusher: flusher,
|
||||
hijacker: hijacker,
|
||||
pusher: pusher,
|
||||
readerFrom: readerFrom,
|
||||
}
|
||||
}
|
||||
|
||||
type minimalResponseWriter struct {
|
||||
header http.Header
|
||||
body strings.Builder
|
||||
status int
|
||||
}
|
||||
|
||||
func newMinimalResponseWriter() *minimalResponseWriter {
|
||||
return &minimalResponseWriter{header: http.Header{}, status: http.StatusOK}
|
||||
}
|
||||
|
||||
func (w *minimalResponseWriter) Header() http.Header { return w.header }
|
||||
|
||||
func (w *minimalResponseWriter) Write(data []byte) (int, error) {
|
||||
return w.body.Write(data)
|
||||
}
|
||||
|
||||
func (w *minimalResponseWriter) WriteHeader(statusCode int) { w.status = statusCode }
|
||||
|
||||
type allOptionalResponseWriter struct {
|
||||
*minimalResponseWriter
|
||||
flushed bool
|
||||
hijacked bool
|
||||
pushedTarget string
|
||||
readFromCalled bool
|
||||
}
|
||||
|
||||
func (w *allOptionalResponseWriter) Flush() { w.flushed = true }
|
||||
|
||||
func (w *allOptionalResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
w.hijacked = true
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func (w *allOptionalResponseWriter) Push(target string, _ *http.PushOptions) error {
|
||||
w.pushedTarget = target
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *allOptionalResponseWriter) ReadFrom(reader io.Reader) (int64, error) {
|
||||
w.readFromCalled = true
|
||||
return io.Copy(w.minimalResponseWriter, reader)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# Contributing
|
||||
|
||||
This package contains the PostHog Go SDK compliance adapter used with the PostHog SDK Test Harness.
|
||||
|
||||
## Running tests
|
||||
|
||||
Tests run automatically in CI via GitHub Actions.
|
||||
|
||||
### Locally with Docker Compose
|
||||
|
||||
Run the full compliance suite from the `sdk_compliance_adapter` directory:
|
||||
|
||||
```bash
|
||||
docker-compose up --build --abort-on-container-exit
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
1. Build the Go SDK adapter
|
||||
2. Pull the test harness image
|
||||
3. Run all compliance tests
|
||||
4. Show the results
|
||||
|
||||
### Manually with Docker
|
||||
|
||||
```bash
|
||||
# Create network
|
||||
docker network create test-network
|
||||
|
||||
# Build and run adapter
|
||||
docker build -f sdk_compliance_adapter/Dockerfile -t posthog-go-adapter .
|
||||
docker run -d --name sdk-adapter --network test-network -p 8080:8080 posthog-go-adapter
|
||||
|
||||
# Run test harness
|
||||
docker run --rm \
|
||||
--name test-harness \
|
||||
--network test-network \
|
||||
ghcr.io/posthog/sdk-test-harness:latest \
|
||||
run --adapter-url http://sdk-adapter:8080 --mock-url http://test-harness:8081
|
||||
|
||||
# Cleanup
|
||||
docker stop sdk-adapter && docker rm sdk-adapter
|
||||
docker network rm test-network
|
||||
```
|
||||
@@ -2,44 +2,9 @@
|
||||
|
||||
This adapter wraps the posthog-go SDK for compliance testing with the [PostHog SDK Test Harness](https://github.com/PostHog/posthog-sdk-test-harness).
|
||||
|
||||
## Running Tests
|
||||
## Contributing
|
||||
|
||||
Tests run automatically in CI via GitHub Actions. See the test harness repo for details.
|
||||
|
||||
### Locally with Docker Compose
|
||||
|
||||
```bash
|
||||
# From the posthog-go/sdk_compliance_adapter directory
|
||||
docker-compose up --build --abort-on-container-exit
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Build the Go SDK adapter
|
||||
2. Pull the test harness image
|
||||
3. Run all compliance tests
|
||||
4. Show results
|
||||
|
||||
### Manually with Docker
|
||||
|
||||
```bash
|
||||
# Create network
|
||||
docker network create test-network
|
||||
|
||||
# Build and run adapter
|
||||
docker build -f sdk_compliance_adapter/Dockerfile -t posthog-go-adapter .
|
||||
docker run -d --name sdk-adapter --network test-network -p 8080:8080 posthog-go-adapter
|
||||
|
||||
# Run test harness
|
||||
docker run --rm \
|
||||
--name test-harness \
|
||||
--network test-network \
|
||||
ghcr.io/posthog/sdk-test-harness:latest \
|
||||
run --adapter-url http://sdk-adapter:8080 --mock-url http://test-harness:8081
|
||||
|
||||
# Cleanup
|
||||
docker stop sdk-adapter && docker rm sdk-adapter
|
||||
docker network rm test-network
|
||||
```
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for local build and compliance test instructions.
|
||||
|
||||
## Implementation
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -100,9 +101,10 @@ var state = &AdapterState{
|
||||
|
||||
// HealthResponse represents /health endpoint response
|
||||
type HealthResponse struct {
|
||||
SDKName string `json:"sdk_name"`
|
||||
SDKVersion string `json:"sdk_version"`
|
||||
AdapterVersion string `json:"adapter_version"`
|
||||
SDKName string `json:"sdk_name"`
|
||||
SDKVersion string `json:"sdk_version"`
|
||||
AdapterVersion string `json:"adapter_version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
}
|
||||
|
||||
// InitRequest represents /init endpoint request
|
||||
@@ -123,6 +125,17 @@ type CaptureRequest struct {
|
||||
Timestamp *string `json:"timestamp,omitempty"`
|
||||
}
|
||||
|
||||
// FeatureFlagRequest represents /get_feature_flag endpoint request
|
||||
type FeatureFlagRequest struct {
|
||||
Key string `json:"key"`
|
||||
DistinctID string `json:"distinct_id"`
|
||||
PersonProperties map[string]interface{} `json:"person_properties,omitempty"`
|
||||
Groups map[string]interface{} `json:"groups,omitempty"`
|
||||
GroupProperties map[string]map[string]interface{} `json:"group_properties,omitempty"`
|
||||
DisableGeoIP *bool `json:"disable_geoip,omitempty"`
|
||||
ForceRemote *bool `json:"force_remote,omitempty"`
|
||||
}
|
||||
|
||||
// StateResponse represents /state endpoint response
|
||||
type StateResponse struct {
|
||||
PendingEvents int `json:"pending_events"`
|
||||
@@ -143,6 +156,7 @@ func healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
SDKName: "insights-go",
|
||||
SDKVersion: insights.Version,
|
||||
AdapterVersion: VERSION,
|
||||
Capabilities: []string{"capture_v0", "encoding_gzip"},
|
||||
}
|
||||
jsonResponse(w, response)
|
||||
}
|
||||
@@ -303,6 +317,109 @@ func stateHandler(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, response)
|
||||
}
|
||||
|
||||
func featureFlagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var req FeatureFlagRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Key == "" {
|
||||
jsonError(w, http.StatusBadRequest, "key is required")
|
||||
return
|
||||
}
|
||||
if req.DistinctID == "" {
|
||||
jsonError(w, http.StatusBadRequest, "distinct_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Sanitize user-controlled fields before they enter the SDK. The SDK
|
||||
// logs flag keys downstream (e.g. featureflags.go's local-eval Warnf),
|
||||
// which CodeQL flags as go/log-injection. Stripping CR/LF here breaks
|
||||
// the data flow at the adapter boundary.
|
||||
req.Key = sanitizeForLog(req.Key)
|
||||
req.DistinctID = sanitizeForLog(req.DistinctID)
|
||||
|
||||
state.mu.Lock()
|
||||
client := state.client
|
||||
state.mu.Unlock()
|
||||
|
||||
if client == nil {
|
||||
jsonError(w, http.StatusBadRequest, "SDK not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
// force_remote defaults to true
|
||||
forceRemote := true
|
||||
if req.ForceRemote != nil {
|
||||
forceRemote = *req.ForceRemote
|
||||
}
|
||||
|
||||
// Note: posthog-go's DisableGeoIP is a client-level config (Config.DisableGeoIP),
|
||||
// not a per-call option on FeatureFlagPayload. We accept the field on the request
|
||||
// for compatibility with the harness contract but do not honor it per-call. The
|
||||
// SDK default (when DisableGeoIP is nil) is to disable geoip on flag requests,
|
||||
// which matches the test harness's expected `geoip_disable: true` assertion.
|
||||
_ = req.DisableGeoIP
|
||||
|
||||
// Convert groups/properties to SDK types
|
||||
var groups posthog.Groups
|
||||
if req.Groups != nil {
|
||||
groups = posthog.Groups(req.Groups)
|
||||
}
|
||||
|
||||
var personProperties posthog.Properties
|
||||
if req.PersonProperties != nil {
|
||||
personProperties = posthog.Properties(req.PersonProperties)
|
||||
}
|
||||
|
||||
var groupProperties map[string]posthog.Properties
|
||||
if req.GroupProperties != nil {
|
||||
groupProperties = make(map[string]posthog.Properties, len(req.GroupProperties))
|
||||
for k, v := range req.GroupProperties {
|
||||
groupProperties[k] = posthog.Properties(v)
|
||||
}
|
||||
}
|
||||
|
||||
value, err := client.GetFeatureFlag(posthog.FeatureFlagPayload{
|
||||
Key: req.Key,
|
||||
DistinctId: req.DistinctID,
|
||||
Groups: groups,
|
||||
PersonProperties: personProperties,
|
||||
GroupProperties: groupProperties,
|
||||
OnlyEvaluateLocally: !forceRemote,
|
||||
})
|
||||
if err != nil {
|
||||
// Avoid logging user-controlled fields (req.Key, req.DistinctID) to prevent log injection.
|
||||
// The error message itself can wrap upstream user-controlled input (e.g. flag keys
|
||||
// quoted in error strings), so strip CR/LF before logging to defang log injection.
|
||||
log.Printf("Error evaluating feature flag: %s", sanitizeForLog(err.Error()))
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Avoid logging user-controlled fields (req.Key, req.DistinctID, value) to prevent log injection.
|
||||
log.Printf("Evaluated feature flag")
|
||||
|
||||
jsonResponse(w, map[string]interface{}{
|
||||
"success": true,
|
||||
"value": value,
|
||||
})
|
||||
}
|
||||
|
||||
// sanitizeForLog strips CR/LF characters from a string before logging, so
|
||||
// user-controlled input (e.g. error strings that quote upstream-supplied flag
|
||||
// keys) cannot inject forged log entries via newline characters.
|
||||
func sanitizeForLog(s string) string {
|
||||
return strings.ReplaceAll(strings.ReplaceAll(s, "\n", " "), "\r", " ")
|
||||
}
|
||||
|
||||
func jsonError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func resetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
@@ -334,6 +451,7 @@ func main() {
|
||||
http.HandleFunc("/flush", flushHandler)
|
||||
http.HandleFunc("/state", stateHandler)
|
||||
http.HandleFunc("/reset", resetHandler)
|
||||
http.HandleFunc("/get_feature_flag", featureFlagHandler)
|
||||
|
||||
log.Printf("Starting Insights Go SDK adapter on port %s", port)
|
||||
log.Fatal(http.ListenAndServe(":"+port, nil))
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package posthog
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type osInfo struct {
|
||||
Name string `json:"$os"`
|
||||
Version string `json:"$os_version,omitempty"`
|
||||
Distro string `json:"$os_distro,omitempty"`
|
||||
}
|
||||
|
||||
type sysContext struct {
|
||||
osInfo
|
||||
GoVersion string `json:"$go_version"`
|
||||
}
|
||||
|
||||
// cachedSystemContext holds the cached system context, computed once per process.
|
||||
// OS info and Go version don't change during runtime, so there's no need to
|
||||
// re-compute (and re-exec sw_vers/uname) on every event.
|
||||
var (
|
||||
cachedSystemContext sysContext
|
||||
cachedSystemContextOnce sync.Once
|
||||
// cachedSysContextProps holds the pre-built properties from the cached system context.
|
||||
// This avoids creating a new Properties map on every ToProperties() call.
|
||||
cachedSysContextProps Properties
|
||||
cachedSysContextPropsOnce sync.Once
|
||||
)
|
||||
|
||||
func getSystemContext() sysContext {
|
||||
cachedSystemContextOnce.Do(func() {
|
||||
cachedSystemContext = sysContext{
|
||||
osInfo: getOSInfo(),
|
||||
GoVersion: runtime.Version(),
|
||||
}
|
||||
})
|
||||
return cachedSystemContext
|
||||
}
|
||||
|
||||
func (ctx sysContext) ToProperties() Properties {
|
||||
cachedSysContextPropsOnce.Do(func() {
|
||||
sc := getSystemContext()
|
||||
props := Properties{
|
||||
"$os": sc.Name,
|
||||
"$go_version": sc.GoVersion,
|
||||
}
|
||||
if sc.Version != "" {
|
||||
props["$os_version"] = sc.Version
|
||||
}
|
||||
if sc.Distro != "" {
|
||||
props["$os_distro"] = sc.Distro
|
||||
}
|
||||
cachedSysContextProps = props
|
||||
})
|
||||
return cachedSysContextProps
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package posthog
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetOSInfo(t *testing.T) {
|
||||
info := getOSInfo()
|
||||
|
||||
expectedNames := map[string]string{
|
||||
"darwin": "Mac OS X",
|
||||
"linux": "Linux",
|
||||
"windows": "Windows",
|
||||
"freebsd": "FreeBSD",
|
||||
"android": "Android",
|
||||
"ios": "iOS",
|
||||
}
|
||||
|
||||
if want, ok := expectedNames[runtime.GOOS]; ok {
|
||||
if info.Name != want {
|
||||
t.Errorf("getOSInfo().Name = %q, want %q", info.Name, want)
|
||||
}
|
||||
} else if info.Name != runtime.GOOS {
|
||||
t.Errorf("getOSInfo().Name = %q, want %q", info.Name, runtime.GOOS)
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
if info.Version == "" {
|
||||
t.Errorf("getOSInfo().Version returned empty string on linux")
|
||||
}
|
||||
if info.Distro == "" {
|
||||
t.Errorf("getOSInfo().Distro returned empty string on linux")
|
||||
}
|
||||
case "darwin", "windows":
|
||||
if info.Version == "" {
|
||||
t.Errorf("getOSInfo().Version returned empty string on %s", runtime.GOOS)
|
||||
}
|
||||
if info.Distro != "" {
|
||||
t.Errorf("getOSInfo().Distro = %q, want empty string on %s", info.Distro, runtime.GOOS)
|
||||
}
|
||||
default:
|
||||
if info.Version != "" {
|
||||
t.Errorf("getOSInfo().Version = %q, want empty string on %s", info.Version, runtime.GOOS)
|
||||
}
|
||||
if info.Distro != "" {
|
||||
t.Errorf("getOSInfo().Distro = %q, want empty string on %s", info.Distro, runtime.GOOS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSystemContext(t *testing.T) {
|
||||
ctx := getSystemContext()
|
||||
|
||||
if ctx.Name != getOSInfo().Name {
|
||||
t.Errorf("getSystemContext().Name = %q, want %q", ctx.Name, getOSInfo().Name)
|
||||
}
|
||||
|
||||
if ctx.Version != getOSInfo().Version {
|
||||
t.Errorf("getSystemContext().Version = %q, want %q", ctx.Version, getOSInfo().Version)
|
||||
}
|
||||
|
||||
if ctx.GoVersion != runtime.Version() {
|
||||
t.Errorf("getSystemContext().GoVersion = %q, want %q", ctx.GoVersion, runtime.Version())
|
||||
}
|
||||
|
||||
if runtime.GOOS == "linux" {
|
||||
if ctx.Distro == "" {
|
||||
t.Errorf("getSystemContext().Distro should be set on linux")
|
||||
}
|
||||
} else {
|
||||
if ctx.Distro != "" {
|
||||
t.Errorf("getSystemContext().Distro = %q, want empty on %s", ctx.Distro, runtime.GOOS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemContextToProperties(t *testing.T) {
|
||||
ctx := getSystemContext()
|
||||
props := ctx.ToProperties()
|
||||
|
||||
if props["$os"] != ctx.Name {
|
||||
t.Errorf("MergeStruct $os = %q, want %q", props["$os"], ctx.Name)
|
||||
}
|
||||
|
||||
if props["$go_version"] != ctx.GoVersion {
|
||||
t.Errorf("MergeStruct $go_version = %q, want %q", props["$go_version"], ctx.GoVersion)
|
||||
}
|
||||
|
||||
if runtime.GOOS == "linux" {
|
||||
if props["$os_version"] == nil || props["$os_version"] == "" {
|
||||
t.Errorf("MergeStruct $os_version should be set on linux")
|
||||
}
|
||||
if props["$os_distro"] == nil || props["$os_distro"] == "" {
|
||||
t.Errorf("MergeStruct $os_distro should be set on linux")
|
||||
}
|
||||
} else if runtime.GOOS == "darwin" || runtime.GOOS == "windows" {
|
||||
if props["$os_version"] == nil || props["$os_version"] == "" {
|
||||
t.Errorf("MergeStruct $os_version should be set on %s", runtime.GOOS)
|
||||
}
|
||||
if _, exists := props["$os_distro"]; exists {
|
||||
t.Errorf("MergeStruct $os_distro should not be set on %s", runtime.GOOS)
|
||||
}
|
||||
} else {
|
||||
if _, exists := props["$os_version"]; exists {
|
||||
t.Errorf("MergeStruct $os_version should not be set on %s", runtime.GOOS)
|
||||
}
|
||||
if _, exists := props["$os_distro"]; exists {
|
||||
t.Errorf("MergeStruct $os_distro should not be set on %s", runtime.GOOS)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -177,7 +177,7 @@ func (b *MockServerBuilder) Build() *httptest.Server {
|
||||
w.Write([]byte(b.config.BatchResponse))
|
||||
}
|
||||
|
||||
case strings.HasPrefix(r.URL.Path, "/flags"):
|
||||
case r.URL.Path == "/flags" || r.URL.Path == "/flags/":
|
||||
if b.config.FlagsHandler != nil {
|
||||
b.config.FlagsHandler(w, r)
|
||||
return
|
||||
@@ -187,7 +187,7 @@ func (b *MockServerBuilder) Build() *httptest.Server {
|
||||
w.Write([]byte(b.config.FlagsResponse))
|
||||
}
|
||||
|
||||
case strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation"):
|
||||
case strings.HasPrefix(r.URL.Path, "/flags/definitions"):
|
||||
if b.config.LocalEvalHandler != nil {
|
||||
b.config.LocalEvalHandler(w, r)
|
||||
return
|
||||
|
||||
+20
-7
@@ -1,14 +1,27 @@
|
||||
package insights
|
||||
|
||||
import "flag"
|
||||
import (
|
||||
"flag"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Version of the client.
|
||||
const Version = "1.10.0"
|
||||
const Version = "1.13.1"
|
||||
|
||||
// make tests easier by using a constant version
|
||||
var (
|
||||
cachedVersion string
|
||||
cachedVersionOnce sync.Once
|
||||
)
|
||||
|
||||
// getVersion returns the SDK version string. The result is cached after first call
|
||||
// to avoid repeated flag.Lookup on every event.
|
||||
func getVersion() string {
|
||||
if flag.Lookup("test.v") != nil {
|
||||
return "1.0.0"
|
||||
}
|
||||
return Version
|
||||
cachedVersionOnce.Do(func() {
|
||||
if flag.Lookup("test.v") != nil {
|
||||
cachedVersion = "1.0.0"
|
||||
} else {
|
||||
cachedVersion = Version
|
||||
}
|
||||
})
|
||||
return cachedVersion
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user