Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 20 additions & 12 deletions docs/install/troubleshooting/truncated-logs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,38 @@ icon: "file-lines"

## Overview

If you see `(truncated)` in the flow run logs, it means the logs have exceeded the maximum allowed file size.
Flow runs have a maximum log size (default **25 MB**). When a run gets close to this limit, Activepieces tries to keep it under the cap by truncating large step **inputs** — you'll see `(truncated)` in place of the original value.

## How It Works
## What Gets Truncated

There is a current limitation where the log file of a run cannot grow past a certain size. When this limit is reached, the engine automatically removes the largest keys in the JSON output until it fits within the allowed size.
Truncation applies to **step inputs only**. Step **outputs are never truncated**, because downstream steps, subflows, and paused/resumed runs need the original output to continue executing correctly. If outputs were dropped, the next step would receive missing data and fail unpredictably.

<Note>
**This does not affect flow execution.** Your flow will continue to run normally even when logs are truncated.
</Note>
The engine works like this:

1. If the total run data fits within the limit, nothing is truncated.
2. Otherwise, step input values are replaced with `(truncated)`, starting from the largest, until the run fits.
3. If the run **still** exceeds the limit after all inputs are truncated — meaning step outputs alone are over the cap — the run fails with `LOG_SIZE_EXCEEDED` and an error like:

## Known Limitation
```
Flow run data size exceeded the maximum allowed size of 25 MB
```

There is one known issue with truncated logs:
## Why Runs Can Still Fail

If you **pause** a flow, then **resume** it, and the resumed step references data from a truncated step, the flow will fail because the referenced data is no longer available in the logs.
If you see this error on a step that downloads or produces a large file (e.g. a multi-megabyte HTTP response, a base64-encoded binary, or a large API payload), the step's **output** is what's pushing the run over the limit. Since outputs cannot be truncated without breaking subsequent steps, the engine has no choice but to fail the run.

## Solution

You can increase the `AP_MAX_FILE_SIZE_MB` environment variable to a higher value to allow larger log files:
Increase the flow run log size limit by setting the `AP_MAX_FLOW_RUN_LOG_SIZE_MB` environment variable:

```bash
AP_MAX_FILE_SIZE_MB=50
AP_MAX_FLOW_RUN_LOG_SIZE_MB=50
```

<Note>
For large file handling, prefer passing files between steps using the built-in file storage (e.g. via `Files` / `File` properties) rather than embedding raw bytes in step outputs. This keeps the run log small and avoids the limit entirely.
</Note>

<Info>
**Future Improvement:** There is a planned enhancement to change this limit from per-log-file to per-step, which will provide more granular control over log sizes. This feature is currently in the planning phase.
**Future Improvement:** A planned enhancement will move this limit from per-run to per-step, giving more granular control over how much data each step can retain.
</Info>
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { Migration } from '../../migration'

export class ReplacesSandboxWithVercelAiSdk1785000000000 implements Migration {
name = 'ReplacesSandboxWithVercelAiSdk1785000000000'
breaking = true
release = '0.84.0'
breaking = false
release = '0.82.1'
transaction = true

public async up(queryRunner: QueryRunner): Promise<void> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { ActivepiecesError, ErrorCode, isNil, SAMLAttributeMapping } from '@activepieces/shared'

export const resolveSamlAttributes = ({ rawAttributes, mapping }: ResolveArgs): SamlAttributes => {
const safeAttributes = rawAttributes ?? {}
const email = pickFirstValue({ source: safeAttributes, keys: candidatesFor({ field: 'email', mapping }) })
const firstName = pickFirstValue({ source: safeAttributes, keys: candidatesFor({ field: 'firstName', mapping }) })
const lastName = pickFirstValue({ source: safeAttributes, keys: candidatesFor({ field: 'lastName', mapping }) })
if (isNil(email) || isNil(firstName) || isNil(lastName)) {
throw missingFieldsError({
resolved: { email, firstName, lastName },
receivedKeys: Object.keys(safeAttributes),
})
}
return { email, firstName, lastName }
}

function candidatesFor({ field, mapping }: CandidatesArgs): string[] {
const override = mapping?.[field]?.trim()
return isNil(override) || override.length === 0
? DEFAULT_KEYS[field]
: [override, ...DEFAULT_KEYS[field]]
}

function pickFirstValue({ source, keys }: PickArgs): string | undefined {
return keys.map((key) => unwrap(source[key])).find(isNonEmptyString)
}

function unwrap(value: unknown): unknown {
return Array.isArray(value) ? value.find(isNonEmptyString) : value
}

function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.length > 0
}

function missingFieldsError({ resolved, receivedKeys }: MissingFieldsErrorArgs): ActivepiecesError {
const missing: string[] = []
if (isNil(resolved.email)) {
missing.push('email')
}
if (isNil(resolved.firstName)) {
missing.push('firstName')
}
if (isNil(resolved.lastName)) {
missing.push('lastName')
}
return new ActivepiecesError({
code: ErrorCode.INVALID_SAML_RESPONSE,
params: {
message: `Invalid SAML response. Missing required field(s): ${missing.join(', ')}. Received attribute keys: [${receivedKeys.join(', ')}]. Configure attributeMapping in SSO settings if your IdP uses non-standard claim names.`,
},
})
}

const DEFAULT_KEYS: Record<keyof SamlAttributes, string[]> = {
email: [
'email',
'emailaddress',
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
],
firstName: [
'firstName',
'firstname',
'givenname',
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname',
],
lastName: [
'lastName',
'lastname',
'surname',
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname',
],
}

type ResolveArgs = {
rawAttributes: Record<string, unknown> | null | undefined
mapping?: SAMLAttributeMapping
}

type CandidatesArgs = {
field: keyof SamlAttributes
mapping: SAMLAttributeMapping | undefined
}

type PickArgs = {
source: Record<string, unknown>
keys: string[]
}

type MissingFieldsErrorArgs = {
resolved: Partial<SamlAttributes>
receivedKeys: string[]
}

export type SamlAttributes = {
email: string
firstName: string
lastName: string
}
Original file line number Diff line number Diff line change
@@ -1,58 +1,9 @@

import { safeHttp } from '@activepieces/server-utils'
import { ActivepiecesError, ErrorCode, SAMLAuthnProviderConfig } from '@activepieces/shared'
import { ActivepiecesError, ErrorCode, SAMLAttributeMapping, SAMLAuthnProviderConfig, tryCatch } from '@activepieces/shared'
import * as validator from '@authenio/samlify-node-xmllint'
import * as saml from 'samlify'
import { z } from 'zod'
import { domainHelper } from '../../custom-domains/domain-helper'


const samlResponseValidator = z.object({
email: z.string(),
firstName: z.string(),
lastName: z.string(),
})

class SamlClient {
private static readonly LOGIN_REQUEST_BINDING = 'redirect'
private static readonly LOGIN_RESPONSE_BINDING = 'post'

constructor(
private readonly idp: saml.IdentityProviderInstance,
private readonly sp: saml.ServiceProviderInstance,
) {}

getLoginUrl(): string {
const loginRequest = this.sp.createLoginRequest(
this.idp,
SamlClient.LOGIN_REQUEST_BINDING,
)

return loginRequest.context
}

async parseAndValidateLoginResponse(idpLoginResponse: IdpLoginResponse): Promise<SamlAttributes> {
const loginResult = await this.sp.parseLoginResponse(
this.idp,
SamlClient.LOGIN_RESPONSE_BINDING,
idpLoginResponse,
)

const atts = loginResult.extract.attributes
if (!samlResponseValidator.safeParse(atts).success) {
throw new ActivepiecesError({
code: ErrorCode.INVALID_SAML_RESPONSE,
params: {
message: 'Invalid SAML response, It should contain these firstName, lastName, email fields.',
},

})
}
return atts
}
}

const instanceCache = new Map<string, SamlClient>()
import { resolveSamlAttributes, SamlAttributes } from './saml-attributes'

export const createSamlClient = async (platformId: string, samlProvider: SAMLAuthnProviderConfig): Promise<SamlClient> => {
const cached = instanceCache.get(platformId)
Expand All @@ -62,8 +13,8 @@ export const createSamlClient = async (platformId: string, samlProvider: SAMLAut
saml.setSchemaValidator(validator)
const metadataXml = await resolveIdpMetadata(samlProvider.idpMetadata)
const idp = createIdp(metadataXml)
const sp = await createSp(platformId, samlProvider.idpCertificate)
const client = new SamlClient(idp, sp)
const sp = await createSp({ platformId, privateKey: samlProvider.idpCertificate })
const client = samlClient({ idp, sp, attributeMapping: samlProvider.attributeMapping })
instanceCache.set(platformId, client)
return client
}
Expand All @@ -72,6 +23,29 @@ export const invalidateSamlClientCache = (platformId: string): void => {
instanceCache.delete(platformId)
}

const samlClient = ({ idp, sp, attributeMapping }: SamlClientArgs) => ({
getLoginUrl(): string {
return sp.createLoginRequest(idp, LOGIN_REQUEST_BINDING).context
},
async parseAndValidateLoginResponse(idpLoginResponse: IdpLoginResponse): Promise<SamlAttributes> {
const { data: loginResult, error: parseError } = await tryCatch(
() => sp.parseLoginResponse(idp, LOGIN_RESPONSE_BINDING, idpLoginResponse),
)
if (parseError !== null) {
throw new ActivepiecesError({
code: ErrorCode.INVALID_SAML_RESPONSE,
params: {
message: `Failed to parse SAML response: ${toErrorMessage(parseError)}`,
},
})
}
return resolveSamlAttributes({
rawAttributes: loginResult.extract?.attributes,
mapping: attributeMapping,
})
},
})

const createIdp = (metadata: string): saml.IdentityProviderInstance => {
return saml.IdentityProvider({
metadata,
Expand All @@ -86,32 +60,34 @@ const resolveIdpMetadata = async (idpMetadata: string): Promise<string> => {
if (!/^https?:\/\//i.test(trimmed)) {
return idpMetadata
}
try {
const response = await safeHttp.axios.get<string>(trimmed, {
responseType: 'text',
timeout: 10_000,
maxContentLength: 5 * 1024 * 1024,
maxBodyLength: 5 * 1024 * 1024,
transformResponse: (data) => data,
const { data: response, error } = await tryCatch(() => safeHttp.axios.get<string>(trimmed, {
responseType: 'text',
timeout: 10_000,
maxContentLength: 5 * 1024 * 1024,
maxBodyLength: 5 * 1024 * 1024,
transformResponse: (data) => data,
}))
if (error !== null) {
throw new ActivepiecesError({
code: ErrorCode.INVALID_SAML_RESPONSE,
params: {
message: `Failed to fetch IdP metadata from URL: ${toErrorMessage(error)}`,
},
})
const contentType = String(response.headers['content-type'] ?? '').toLowerCase()
if (contentType !== '' && !contentType.includes('xml') && !contentType.includes('text/plain')) {
throw new Error(`Unexpected content-type "${contentType}" — expected XML.`)
}
return typeof response.data === 'string' ? response.data : String(response.data)
}
catch (error) {
const message = error instanceof Error ? error.message : String(error)
const contentType = String(response.headers['content-type'] ?? '').toLowerCase()
if (contentType !== '' && !contentType.includes('xml') && !contentType.includes('text/plain')) {
throw new ActivepiecesError({
code: ErrorCode.INVALID_SAML_RESPONSE,
params: {
message: `Failed to fetch IdP metadata from URL: ${message}`,
message: `Failed to fetch IdP metadata from URL: Unexpected content-type "${contentType}" — expected XML.`,
},
})
}
return typeof response.data === 'string' ? response.data : String(response.data)
}

const createSp = async (platformId: string, privateKey: string): Promise<saml.ServiceProviderInstance> => {
const createSp = async ({ platformId, privateKey }: CreateSpArgs): Promise<saml.ServiceProviderInstance> => {
const acsUrl = await domainHelper.getPublicUrl({ path: '/api/v1/authn/saml/acs', platformId })
return saml.ServiceProvider({
entityID: 'Activepieces',
Expand All @@ -129,13 +105,31 @@ const createSp = async (platformId: string, privateKey: string): Promise<saml.Se
})
}

const toErrorMessage = (error: unknown): string => {
return error instanceof Error ? error.message : String(error)
}

const LOGIN_REQUEST_BINDING = 'redirect'
const LOGIN_RESPONSE_BINDING = 'post'

const instanceCache = new Map<string, SamlClient>()

type SamlClient = ReturnType<typeof samlClient>

type SamlClientArgs = {
idp: saml.IdentityProviderInstance
sp: saml.ServiceProviderInstance
attributeMapping: SAMLAttributeMapping | undefined
}

type CreateSpArgs = {
platformId: string
privateKey: string
}

export type IdpLoginResponse = {
body: Record<string, unknown>
query: Record<string, unknown>
}

export type SamlAttributes = {
email: string
firstName: string
lastName: string
}
export type { SamlAttributes } from './saml-attributes'
Loading
Loading